session-ios/Session/Open Groups/OpenGroupSuggestionGrid.swift

411 lines
16 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
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()
.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 / 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(receiveOn: DispatchQueue.main) { 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
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
}
}