Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalUI/AttachmentApproval/ExpandableContactListView.swift
1 views
//
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import UIKit

class ExpandableContactListView: UIView {

    private class var listFormatter: ListFormatter {
        let formatter = ListFormatter()
        if let identifier = NSLocale.preferredLanguages.first {
            formatter.locale = Locale(identifier: identifier)
        }
        return formatter
    }

    var contactNames: [String] = [] {
        didSet {
            textLabel.text = Self.listFormatter.string(from: contactNames)
        }
    }

    var expanded: Bool = false {
        didSet {
            scrollView.isScrollEnabled = expanded
            scrollViewMaxWidthConstraint?.isActive = !expanded
            if !expanded {
                scrollView.contentOffset = .zero
            }
        }
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        textLabel.textColor = tintColor

        let pillView = PillView()
        pillView.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0)
        pillView.autoSetDimension(.height, toSize: RoundMediaButton.visibleButtonSize)
        addSubview(pillView)
        pillView.autoPinEdgesToSuperviewEdges()

        let backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
        pillView.addSubview(backgroundView)
        backgroundView.autoPinEdgesToSuperviewEdges()

        let arrowView = UIImageView(image: UIImage(imageLiteralResourceName: "arrow-up-compact"))
        pillView.addSubview(arrowView)
        arrowView.autoPinEdge(toSuperviewMargin: .leading, withInset: 2)
        arrowView.autoVCenterInSuperview()

        scrollViewContainer.clipsToBounds = true
        scrollViewContainer.layoutMargins = UIEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: ExpandableContactListView.gradientWidth)
        pillView.addSubview(scrollViewContainer)
        scrollViewContainer.autoPinEdges(toSuperviewMarginsExcludingEdge: .leading)
        scrollViewContainer.leadingAnchor.constraint(equalTo: arrowView.trailingAnchor, constant: 4).isActive = true

        scrollView.delegate = self
        scrollView.clipsToBounds = false
        scrollView.isScrollEnabled = expanded
        scrollViewContainer.addSubview(scrollView)
        scrollView.autoPinEdgesToSuperviewMargins()

        scrollView.addSubview(textLabel)
        textLabel.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor).isActive = true
        textLabel.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor).isActive = true
        textLabel.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor).isActive = true
        textLabel.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor).isActive = true
        scrollView.heightAnchor.constraint(equalTo: textLabel.heightAnchor).isActive = true

        // This constraint sets intrinsic content width on the scroll view.
        addConstraint({
            let constraint = scrollView.widthAnchor.constraint(equalTo: textLabel.widthAnchor)
            constraint.priority = .defaultLow
            return constraint
        }())

        // Limit scroll view width in expanded state to 128 pts.
        let scrollViewMaxWidthConstraint = scrollViewContainer.widthAnchor.constraint(lessThanOrEqualToConstant: 128)
        if !expanded {
            addConstraint(scrollViewMaxWidthConstraint)
        }
        self.scrollViewMaxWidthConstraint = scrollViewMaxWidthConstraint

        addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleSingleTap(gestureRecognizer:))))
    }

    @available(*, unavailable, message: "Use init(frame:)")
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func tintColorDidChange() {
        super.tintColorDidChange()
        textLabel.textColor = tintColor
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        DispatchQueue.main.async {
            self.updateTextLabelEdgesFading()
        }
    }

    // MARK: - Layout

    private let scrollViewContainer = UIView()

    private let scrollView: UIScrollView = {
        let scrollView = UIScrollView()
        scrollView.showsVerticalScrollIndicator = false
        scrollView.showsHorizontalScrollIndicator = false
        scrollView.alwaysBounceHorizontal = false
        return scrollView
    }()

    private let textLabel: UILabel = {
        let label = UILabel()
        label.numberOfLines = 1
        label.lineBreakMode = .byClipping
        label.font = .dynamicTypeSubheadlineClamped
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private var scrollViewMaxWidthConstraint: NSLayoutConstraint?
    private static let gradientWidth: CGFloat = 14
    private var isLeadingEdgeFaded = false
    private var isTrailingEdgeFaded = false

    private func updateTextLabelEdgesFading() {

        // This method would be called in a tight loop when users scrolls.
        // Therefore only re-create mask layer if it is necessary.
        let shouldFadeLeading = scrollView.contentOffset.x > 0
        let shouldFadeTrailing = scrollView.contentOffset.x < scrollView.contentSize.width - scrollView.frame.width
        var shouldUpdateLayerMask = shouldFadeLeading != isLeadingEdgeFaded || shouldFadeTrailing != isTrailingEdgeFaded

        // Mask layer doesn't resize automatically and therefore width change
        // (switching to/from expanded state) mandates mask update.
        if !shouldUpdateLayerMask, let maskLayer = scrollViewContainer.layer.mask {
            shouldUpdateLayerMask = maskLayer.bounds.width != scrollViewContainer.width
        }

        guard shouldUpdateLayerMask else {
            return
        }

        isLeadingEdgeFaded = shouldFadeLeading
        isTrailingEdgeFaded = shouldFadeTrailing

        // Simplest case: no edge fading - no mask layer.
        guard isLeadingEdgeFaded || isTrailingEdgeFaded else {
            scrollViewContainer.layer.mask = nil
            return
        }

        let gradientWidthInPercent = Self.gradientWidth / scrollViewContainer.width

        let gradientStopLocations: [CGFloat] = [0, gradientWidthInPercent, 1 - gradientWidthInPercent, 1]
        var gradientColors: [UIColor] = [.black, .black]
        gradientColors.insert(isLeadingEdgeFaded ? .clear : .black, at: 0)
        gradientColors.append(isTrailingEdgeFaded ? .clear : .black)

        let gradientLayer = CAGradientLayer()
        gradientLayer.frame = scrollViewContainer.bounds
        gradientLayer.startPoint = CGPoint(x: 0, y: 0.5)
        gradientLayer.endPoint = CGPoint(x: 1, y: 0.5)
        gradientLayer.colors = gradientColors.map { $0.cgColor }
        gradientLayer.locations = gradientStopLocations.map { NSNumber(value: $0) }
        scrollViewContainer.layer.mask = gradientLayer
    }
}

extension ExpandableContactListView {

    @objc
    private func handleSingleTap(gestureRecognizer: UITapGestureRecognizer) {
        expanded = !expanded
        UIView.animate(
            withDuration: 0.3,
            animations: {
                self.superview?.setNeedsLayout()
                self.superview?.layoutIfNeeded()
                if self.expanded {
                    self.updateTextLabelEdgesFading()
                }
            },
            completion: { _ in
                self.updateTextLabelEdgesFading()
            },
        )
    }
}

extension ExpandableContactListView: UIScrollViewDelegate {

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        updateTextLabelEdgesFading()
    }
}