// 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 fileprivate static let numHorizontalCells: Int = (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() .subscribe(on: DispatchQueue.global(qos: .default)) .receive(on: DispatchQueue.main, immediatelyIfMain: true) .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 / CGFloat(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 { let totalItems: Int = collectionView.numberOfItems(inSection: indexPath.section) let itemsInFinalRow: Int = (totalItems % OpenGroupSuggestionGrid.numHorizontalCells) guard indexPath.item >= (totalItems - itemsInFinalRow) && itemsInFinalRow != 0 else { let cellWidth: CGFloat = ((maxWidth / CGFloat(OpenGroupSuggestionGrid.numHorizontalCells)) - ((CGFloat(OpenGroupSuggestionGrid.numHorizontalCells) - 1) * layout.minimumInteritemSpacing)) return CGSize(width: cellWidth, height: OpenGroupSuggestionGrid.cellHeight) } // If there isn't an even number of items then we want to calculate proper sizing 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() ) .subscribe(on: DispatchQueue.global(qos: .userInitiated)) .receive(on: DispatchQueue.main, immediatelyIfMain: 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 let remainingItems: Int = elementAttributes.map({ $0.count % OpenGroupSuggestionGrid.numHorizontalCells }), remainingItems != 0, let lastItems: [UICollectionViewLayoutAttributes] = elementAttributes?.suffix(remainingItems), !lastItems.isEmpty else { return elementAttributes } let totalItemWidth: CGFloat = lastItems .map { $0.frame.size.width } .reduce(0, +) let lastRowWidth: CGFloat = (totalItemWidth + (CGFloat(lastItems.count - 1) * minimumInteritemSpacing)) // Offset the start width by half of the remaining space var itemXPos: CGFloat = ((targetViewWidth - lastRowWidth) / 2) lastItems.forEach { item in item.frame = CGRect( x: itemXPos, y: item.frame.origin.y, width: item.frame.size.width, height: item.frame.size.height ) itemXPos += (item.frame.size.width + minimumInteritemSpacing) } return elementAttributes } }