mirror of https://github.com/oxen-io/session-ios
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
397 lines
15 KiB
Swift
397 lines
15 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import NVActivityIndicatorView
|
|
import SessionMessagingKit
|
|
import SessionUIKit
|
|
|
|
final class OpenGroupSuggestionGrid: UIView, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
|
|
private let itemsPerSection: Int = (UIDevice.current.isIPad ? 4 : 2)
|
|
private var maxWidth: CGFloat
|
|
private var rooms: [OpenGroupAPI.Room] = [] { didSet { update() } }
|
|
private var heightConstraint: NSLayoutConstraint!
|
|
|
|
var delegate: OpenGroupSuggestionGridDelegate?
|
|
|
|
// MARK: - UI
|
|
|
|
private static let cellHeight: CGFloat = 40
|
|
private static let separatorWidth = Values.separatorThickness
|
|
private static let numHorizontalCells: CGFloat = (UIDevice.current.isIPad ? 4 : 2)
|
|
|
|
private lazy var layout: LastRowCenteredLayout = {
|
|
let result = LastRowCenteredLayout()
|
|
result.minimumLineSpacing = Values.mediumSpacing
|
|
result.minimumInteritemSpacing = Values.mediumSpacing
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var collectionView: UICollectionView = {
|
|
let result = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
|
result.themeBackgroundColor = .clear
|
|
result.isScrollEnabled = false
|
|
result.register(view: Cell.self)
|
|
result.dataSource = self
|
|
result.delegate = self
|
|
|
|
return result
|
|
}()
|
|
|
|
private let spinner: NVActivityIndicatorView = {
|
|
let result: NVActivityIndicatorView = NVActivityIndicatorView(
|
|
frame: CGRect.zero,
|
|
type: .circleStrokeSpin,
|
|
color: .black,
|
|
padding: nil
|
|
)
|
|
result.set(.width, to: OpenGroupSuggestionGrid.cellHeight)
|
|
result.set(.height, to: OpenGroupSuggestionGrid.cellHeight)
|
|
|
|
ThemeManager.onThemeChange(observer: result) { [weak result] theme, _ in
|
|
guard let textPrimary: UIColor = theme.color(for: .textPrimary) else { return }
|
|
|
|
result?.color = textPrimary
|
|
}
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var errorView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.isHidden = true
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var errorImageView: UIImageView = {
|
|
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "warning").withRenderingMode(.alwaysTemplate))
|
|
result.themeTintColor = .danger
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var errorTitleLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .systemFont(ofSize: Values.mediumFontSize, weight: .medium)
|
|
result.text = "DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE".localized()
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
result.numberOfLines = 0
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var errorSubtitleLabel: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = .systemFont(ofSize: Values.smallFontSize, weight: .medium)
|
|
result.text = "DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE".localized()
|
|
result.themeTextColor = .textPrimary
|
|
result.textAlignment = .center
|
|
result.numberOfLines = 0
|
|
|
|
return result
|
|
}()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(maxWidth: CGFloat) {
|
|
self.maxWidth = maxWidth
|
|
|
|
super.init(frame: CGRect.zero)
|
|
|
|
initialize()
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
preconditionFailure("Use init(maxWidth:) instead.")
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
preconditionFailure("Use init(maxWidth:) instead.")
|
|
}
|
|
|
|
private func initialize() {
|
|
addSubview(collectionView)
|
|
collectionView.pin(to: self)
|
|
|
|
addSubview(spinner)
|
|
spinner.pin(.top, to: .top, of: self)
|
|
spinner.center(.horizontal, in: self)
|
|
spinner.startAnimating()
|
|
|
|
addSubview(errorView)
|
|
errorView.pin(.top, to: .top, of: self, withInset: 10)
|
|
errorView.pin( [HorizontalEdge.leading, HorizontalEdge.trailing], to: self)
|
|
|
|
errorView.addSubview(errorImageView)
|
|
errorImageView.pin(.top, to: .top, of: errorView)
|
|
errorImageView.center(.horizontal, in: errorView)
|
|
errorImageView.set(.width, to: 60)
|
|
errorImageView.set(.height, to: 60)
|
|
|
|
errorView.addSubview(errorTitleLabel)
|
|
errorTitleLabel.pin(.top, to: .bottom, of: errorImageView, withInset: 10)
|
|
errorTitleLabel.center(.horizontal, in: errorView)
|
|
|
|
errorView.addSubview(errorSubtitleLabel)
|
|
errorSubtitleLabel.pin(.top, to: .bottom, of: errorTitleLabel, withInset: 20)
|
|
errorSubtitleLabel.center(.horizontal, in: errorView)
|
|
|
|
heightConstraint = set(.height, to: OpenGroupSuggestionGrid.cellHeight)
|
|
widthAnchor.constraint(greaterThanOrEqualToConstant: OpenGroupSuggestionGrid.cellHeight).isActive = true
|
|
|
|
OpenGroupManager.getDefaultRoomsIfNeeded()
|
|
.receive(on: DispatchQueue.main)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { [weak self] _ in self?.update() },
|
|
receiveValue: { [weak self] rooms in self?.rooms = rooms }
|
|
)
|
|
}
|
|
|
|
// MARK: - Updating
|
|
|
|
private func update() {
|
|
spinner.stopAnimating()
|
|
spinner.isHidden = true
|
|
|
|
let roomCount: CGFloat = CGFloat(min(rooms.count, 8)) // Cap to a maximum of 8 (4 rows of 2)
|
|
let numRows: CGFloat = ceil(roomCount / OpenGroupSuggestionGrid.numHorizontalCells)
|
|
let height: CGFloat = ((OpenGroupSuggestionGrid.cellHeight * numRows) + ((numRows - 1) * layout.minimumLineSpacing))
|
|
heightConstraint.constant = height
|
|
collectionView.reloadData()
|
|
errorView.isHidden = (roomCount > 0)
|
|
}
|
|
|
|
public func refreshLayout(with maxWidth: CGFloat) {
|
|
self.maxWidth = maxWidth
|
|
collectionView.collectionViewLayout.invalidateLayout()
|
|
}
|
|
|
|
// MARK: - Layout
|
|
|
|
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
|
|
guard
|
|
indexPath.item == (collectionView.numberOfItems(inSection: indexPath.section) - 1) &&
|
|
indexPath.item % 2 == 0
|
|
else {
|
|
let cellWidth: CGFloat = ((maxWidth / OpenGroupSuggestionGrid.numHorizontalCells) - ((OpenGroupSuggestionGrid.numHorizontalCells - 1) * layout.minimumInteritemSpacing))
|
|
|
|
return CGSize(width: cellWidth, height: OpenGroupSuggestionGrid.cellHeight)
|
|
}
|
|
|
|
// If the last item is by itself then we want to make it wider
|
|
return CGSize(
|
|
width: (Cell.calculatedWith(for: rooms[indexPath.item].name)),
|
|
height: OpenGroupSuggestionGrid.cellHeight
|
|
)
|
|
}
|
|
|
|
// MARK: - Data Source
|
|
|
|
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
|
|
return min(rooms.count, 8) // Cap to a maximum of 8 (4 rows of 2)
|
|
}
|
|
|
|
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
|
|
let cell: Cell = collectionView.dequeue(type: Cell.self, for: indexPath)
|
|
cell.room = rooms[indexPath.item]
|
|
|
|
return cell
|
|
}
|
|
|
|
// MARK: - Interaction
|
|
|
|
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
|
|
let room = rooms[indexPath.section * itemsPerSection + indexPath.item]
|
|
delegate?.join(room)
|
|
}
|
|
}
|
|
|
|
// MARK: - Cell
|
|
|
|
extension OpenGroupSuggestionGrid {
|
|
fileprivate final class Cell: UICollectionViewCell {
|
|
private static let labelFont: UIFont = .systemFont(ofSize: Values.smallFontSize)
|
|
private static let imageSize: CGFloat = 30
|
|
private static let itemPadding: CGFloat = Values.smallSpacing
|
|
private static let contentLeftPadding: CGFloat = 7
|
|
private static let contentRightPadding: CGFloat = Values.veryLargeSpacing
|
|
|
|
fileprivate static func calculatedWith(for title: String) -> CGFloat {
|
|
// FIXME: Do the calculations properly in the 'LastRowCenteredLayout' to handle imageless cells
|
|
return (
|
|
contentLeftPadding +
|
|
imageSize +
|
|
itemPadding +
|
|
NSAttributedString(string: title, attributes: [ .font: labelFont ]).size().width +
|
|
contentRightPadding +
|
|
1 // Not sure why this is needed but it seems things are sometimes truncated without it
|
|
)
|
|
}
|
|
|
|
var room: OpenGroupAPI.Room? { didSet { update() } }
|
|
|
|
private lazy var snContentView: UIView = {
|
|
let result: UIView = UIView()
|
|
result.themeBorderColor = .borderSeparator
|
|
result.layer.cornerRadius = Cell.contentViewCornerRadius
|
|
result.layer.borderWidth = 1
|
|
result.set(.height, to: Cell.contentViewHeight)
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var imageView: UIImageView = {
|
|
let result: UIImageView = UIImageView()
|
|
result.set(.width, to: Cell.imageSize)
|
|
result.set(.height, to: Cell.imageSize)
|
|
result.layer.cornerRadius = (Cell.imageSize / 2)
|
|
result.clipsToBounds = true
|
|
|
|
return result
|
|
}()
|
|
|
|
private lazy var label: UILabel = {
|
|
let result: UILabel = UILabel()
|
|
result.font = Cell.labelFont
|
|
result.themeTextColor = .textPrimary
|
|
result.lineBreakMode = .byTruncatingTail
|
|
|
|
return result
|
|
}()
|
|
|
|
private static let contentViewInset: CGFloat = 0
|
|
private static var contentViewHeight: CGFloat { OpenGroupSuggestionGrid.cellHeight - 2 * contentViewInset }
|
|
private static var contentViewCornerRadius: CGFloat { contentViewHeight / 2 }
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
setUpViewHierarchy()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
|
|
setUpViewHierarchy()
|
|
}
|
|
|
|
private func setUpViewHierarchy() {
|
|
backgroundView = UIView()
|
|
backgroundView?.themeBackgroundColor = .backgroundPrimary
|
|
backgroundView?.layer.cornerRadius = Cell.contentViewCornerRadius
|
|
|
|
selectedBackgroundView = UIView()
|
|
selectedBackgroundView?.themeBackgroundColor = .backgroundSecondary
|
|
selectedBackgroundView?.layer.cornerRadius = Cell.contentViewCornerRadius
|
|
|
|
addSubview(snContentView)
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [ imageView, label ])
|
|
stackView.axis = .horizontal
|
|
stackView.spacing = Cell.itemPadding
|
|
snContentView.addSubview(stackView)
|
|
|
|
stackView.center(.vertical, in: snContentView)
|
|
stackView.pin(.leading, to: .leading, of: snContentView, withInset: Cell.contentLeftPadding)
|
|
|
|
snContentView.trailingAnchor
|
|
.constraint(
|
|
greaterThanOrEqualTo: stackView.trailingAnchor,
|
|
constant: Cell.contentRightPadding
|
|
)
|
|
.isActive = true
|
|
snContentView.pin(to: self)
|
|
}
|
|
|
|
private func update() {
|
|
guard let room: OpenGroupAPI.Room = room else { return }
|
|
|
|
label.text = room.name
|
|
|
|
// Only continue if we have a room image
|
|
guard let imageId: String = room.imageId else {
|
|
imageView.isHidden = true
|
|
return
|
|
}
|
|
|
|
imageView.image = nil
|
|
|
|
Publishers
|
|
.MergeMany(
|
|
Storage.shared
|
|
.readPublisherFlatMap { db in
|
|
OpenGroupManager
|
|
.roomImage(db, fileId: imageId, for: room.token, on: OpenGroupAPI.defaultServer)
|
|
}
|
|
.map { ($0, true) }
|
|
.eraseToAnyPublisher(),
|
|
// If we have already received the room image then the above will emit first and
|
|
// we can ignore this 'Just' call which is used to hide the image while loading
|
|
Just((Data(), false))
|
|
.setFailureType(to: Error.self)
|
|
.delay(for: .milliseconds(10), scheduler: DispatchQueue.main)
|
|
.eraseToAnyPublisher()
|
|
)
|
|
.receiveOnMain(immediately: true)
|
|
.sinkUntilComplete(
|
|
receiveValue: { [weak self] imageData, hasData in
|
|
guard hasData else {
|
|
// This will emit twice (once with the data and once without it), if we
|
|
// have actually received the images then we don't want the second emission
|
|
// to hide the imageView anymore
|
|
if self?.imageView.image == nil {
|
|
self?.imageView.isHidden = true
|
|
}
|
|
return
|
|
}
|
|
|
|
self?.imageView.image = UIImage(data: imageData)
|
|
self?.imageView.isHidden = (self?.imageView.image == nil)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Delegate
|
|
|
|
protocol OpenGroupSuggestionGridDelegate {
|
|
func join(_ room: OpenGroupAPI.Room)
|
|
}
|
|
|
|
// MARK: - LastRowCenteredLayout
|
|
|
|
class LastRowCenteredLayout: UICollectionViewFlowLayout {
|
|
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
|
|
// If we have an odd number of items then we want to center the last one horizontally
|
|
let elementAttributes: [UICollectionViewLayoutAttributes]? = super.layoutAttributesForElements(in: rect)
|
|
|
|
// It looks like on "max" devices the rect we are given can be much larger than the size of the
|
|
// collection view, as a result we need to try and use the collectionView width here instead
|
|
let targetViewWidth: CGFloat = {
|
|
guard let collectionView: UICollectionView = self.collectionView, collectionView.frame.width > 0 else {
|
|
return rect.width
|
|
}
|
|
|
|
return collectionView.frame.width
|
|
}()
|
|
|
|
guard
|
|
(elementAttributes?.count ?? 0) % 2 == 1,
|
|
let lastItemAttributes: UICollectionViewLayoutAttributes = elementAttributes?.last
|
|
else { return elementAttributes }
|
|
|
|
lastItemAttributes.frame = CGRect(
|
|
x: ((targetViewWidth - lastItemAttributes.frame.size.width) / 2),
|
|
y: lastItemAttributes.frame.origin.y,
|
|
width: lastItemAttributes.frame.size.width,
|
|
height: lastItemAttributes.frame.size.height
|
|
)
|
|
|
|
return elementAttributes
|
|
}
|
|
}
|