Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/AppSettings/Profile/BadgeCollectionView.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

protocol BadgeCollectionDataSource: AnyObject {
    var availableBadges: [OWSUserProfileBadgeInfo] { get }
    var selectedBadgeIndex: Int { get set }
    func present(_ viewControllerToPresent: UIViewController, animated flag: Bool, completion: (() -> Void)?)
}

class BadgeCollectionView: UICollectionView {
    private weak var badgeDataSource: BadgeCollectionDataSource?

    enum SelectionMode: Equatable {
        case none
        case feature
        case detailsSheet(owner: BadgeDetailsSheet.Owner)
    }

    var badgeSelectionMode: SelectionMode = .none {
        didSet {
            indexPathsForSelectedItems?.forEach { deselectItem(at: $0, animated: false) }
            allowsSelection = badgeSelectionMode != .none

            if case .feature = badgeSelectionMode, let selectedIdx = badgeDataSource?.selectedBadgeIndex {
                let indexPath = IndexPath(item: selectedIdx, section: 0)
                selectItem(at: indexPath, animated: false, scrollPosition: [])
            }
        }
    }

    private let reuseIdentifier = "BadgeCollectionViewCell"
    private let flowLayout: UICollectionViewFlowLayout = {
        // TODO: Swap this out for a custom layout. As best I can tell, flow layout doesn't support left alignment
        let layout = UICollectionViewFlowLayout()
        layout.minimumLineSpacing = 12
        layout.minimumInteritemSpacing = 2
        layout.scrollDirection = .vertical
        return layout
    }()

    init(dataSource: BadgeCollectionDataSource) {
        super.init(frame: .zero, collectionViewLayout: flowLayout)
        register(BadgeCollectionViewCell.self, forCellWithReuseIdentifier: reuseIdentifier)
        backgroundColor = .clear
        badgeDataSource = dataSource
        allowsSelection = false
        self.dataSource = self
        self.delegate = self
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    // MARK: - Sizing

    // For now, the number of badges is small enough that there's no need to enable scrolling
    // Instead, let's just pin the intrinsic height to the size of its content so autolayout
    // sizes us appropriately.

    override func reloadData() {
        super.reloadData()
        _cellSize = nil
        invalidateIntrinsicContentSize()
    }

    override var intrinsicContentSize: CGSize { contentSize }
    override var contentSize: CGSize {
        didSet {
            if contentSize != oldValue {
                _cellSize = nil
                invalidateIntrinsicContentSize()
            }
        }
    }

    override var bounds: CGRect {
        didSet {
            if bounds != oldValue {
                _cellSize = nil
                invalidateIntrinsicContentSize()
            }
        }
    }

    // TODO: This should be replaced once we move to a custom layout
    var _cellSize: CGSize?
    var cellSize: CGSize {
        return _cellSize ?? {
            let badges = badgeDataSource?.availableBadges ?? []

            // If we only have one cell, its width can be the size of the view
            let availableWidth = bounds.inset(by: layoutMargins).size.width
            let cellWidth: CGFloat = (badges.count > 1) ? 78 : availableWidth

            // For the height, some cells may have a multiline name label. In that case, we want
            // the cells to all size to match the largest height
            let testCell = BadgeCollectionViewCell(frame: .zero)
            let cellHeights: [CGFloat] = badges.map {
                testCell.prepareForReuse()
                testCell.applyBadge($0)
                let fittingSize = testCell.sizeThatFits(.init(width: cellWidth, height: .infinity))
                return fittingSize.height
            }
            let maxHeight = cellHeights.max() ?? 0
            let size = CGSize(width: cellWidth, height: maxHeight)
            _cellSize = size
            return size
        }()
    }
}

extension BadgeCollectionView: UICollectionViewDelegateFlowLayout, UICollectionViewDataSource {

    // MARK: Selection

    func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
        return badgeSelectionMode != .none
    }

    func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
        return badgeSelectionMode != .none
    }

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        switch badgeSelectionMode {
        case .none:
            owsFailDebug("This should never happen")
        case .feature:
            badgeDataSource?.selectedBadgeIndex = indexPath.item
        case .detailsSheet(let owner):
            collectionView.deselectItem(at: indexPath, animated: false)

            guard let dataSource = badgeDataSource else { return }

            let badgeInfo = dataSource.availableBadges[indexPath.item]
            SSKEnvironment.shared.databaseStorageRef.read { badgeInfo.loadBadge(transaction: $0) }
            guard let badge = badgeInfo.badge else {
                return owsFailDebug("Unexpectedly missing badge")
            }

            let detailsSheet = BadgeDetailsSheet(
                focusedBadge: badge,
                owner: owner,
            )
            dataSource.present(detailsSheet, animated: true, completion: nil)
        }
    }

    // MARK: Cell data provider

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        if section == 0 {
            return badgeDataSource?.availableBadges.count ?? 0
        } else {
            return 0
        }
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let newCell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier, for: indexPath)
        if
            let badgeCell = newCell as? BadgeCollectionViewCell,
            let badge = badgeDataSource?.availableBadges[safe: indexPath.item]
        {
            badgeCell.applyBadge(badge)
        } else {
            owsFailDebug("Invalid badge")
        }
        return newCell
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout collectionViewLayout: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath,
    ) -> CGSize {
        cellSize
    }
}

class BadgeCollectionViewCell: UICollectionViewCell {
    let badgeImageViewSize = CGSize(square: 64)
    let badgeImageOffset: CGFloat = 8

    lazy var badgeImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.layer.minificationFilter = .trilinear
        imageView.autoSetDimensions(to: badgeImageViewSize)
        return imageView
    }()

    lazy var badgeSelectionCheck: UIImageView = {
        let imageView = UIImageView()
        let imageViewSize = CGSize(square: 24)

        imageView.contentMode = .scaleAspectFit
        imageView.autoSetDimensions(to: imageViewSize)
        imageView.setTemplateImage(Theme.iconImage(.checkCircleFill), tintColor: .ows_accentBlue)
        imageView.isHidden = true

        imageView.backgroundColor = .ows_gray02
        imageView.layer.cornerRadius = imageViewSize.largerAxis / 2

        return imageView
    }()

    let badgeSubtitleView: UILabel = {
        let subtitle = UILabel()
        subtitle.font = .dynamicTypeCaption1Clamped
        subtitle.numberOfLines = 3
        return subtitle
    }()

    override init(frame: CGRect) {
        super.init(frame: .zero)
        contentView.addSubview(badgeImageView)
        contentView.addSubview(badgeSelectionCheck)
        contentView.addSubview(badgeSubtitleView)

        badgeImageView.autoPinEdge(toSuperviewEdge: .top)
        badgeSubtitleView.autoPinEdge(.top, to: .bottom, of: badgeImageView, withOffset: badgeImageOffset)
        badgeSubtitleView.autoPinEdge(toSuperviewEdge: .bottom)

        badgeImageView.autoHCenterInSuperview()
        badgeImageView.autoPinWidthToSuperview(relation: .lessThanOrEqual)
        badgeSubtitleView.autoHCenterInSuperview()
        badgeSubtitleView.autoPinWidthToSuperview(relation: .lessThanOrEqual)

        badgeSelectionCheck.autoPinEdge(.top, to: .top, of: badgeImageView)
        badgeSelectionCheck.autoPinEdge(.trailing, to: .trailing, of: badgeImageView)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var isSelected: Bool {
        didSet {
            badgeSelectionCheck.isHidden = !isSelected
        }
    }

    func applyBadge(_ profileBadge: OWSUserProfileBadgeInfo) {
        badgeImageView.image = profileBadge.badge?.assets?.universal160
        badgeSubtitleView.text = profileBadge.badge?.localizedName
    }

    override func prepareForReuse() {
        super.prepareForReuse()
        badgeImageView.image = nil
        badgeSubtitleView.text = nil
        isSelected = false
    }

    override func sizeThatFits(_ size: CGSize) -> CGSize {
        let imageSize = badgeImageViewSize
        let labelFittingSize = badgeSubtitleView.sizeThatFits(size)

        let desiredWidth = max(imageSize.width, labelFittingSize.width)
        let fittingWidth = min(desiredWidth, size.width)

        let desiredHeight = imageSize.height + badgeImageOffset + labelFittingSize.height
        let fittingHeight = min(desiredHeight, size.height)

        return CGSize(width: fittingWidth, height: fittingHeight)
    }
}