TM-SGNL-iOS/SignalUI/Views/GalleryRailView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

371 lines
12 KiB
Swift

//
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public protocol GalleryRailItemProvider: AnyObject {
var railItems: [GalleryRailItem] { get }
}
public protocol GalleryRailItem {
func buildRailItemView() -> UIView
func isEqualToGalleryRailItem(_ other: GalleryRailItem?) -> Bool
}
public extension GalleryRailItem where Self: Equatable {
func isEqualToGalleryRailItem(_ other: GalleryRailItem?) -> Bool {
guard let other = other as? Self else {
return false
}
return self == other
}
}
protocol GalleryRailCellViewDelegate: AnyObject {
func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView)
}
public struct GalleryRailCellConfiguration {
public let cornerRadius: CGFloat
public let itemBorderWidth: CGFloat
public let itemBorderColor: UIColor?
public let focusedItemBorderWidth: CGFloat
public let focusedItemBorderColor: UIColor?
public let focusedItemOverlayColor: UIColor?
public static var empty: GalleryRailCellConfiguration {
GalleryRailCellConfiguration(
cornerRadius: 0,
itemBorderWidth: 0,
itemBorderColor: nil,
focusedItemBorderWidth: 0,
focusedItemBorderColor: nil,
focusedItemOverlayColor: nil
)
}
public init(
cornerRadius: CGFloat,
itemBorderWidth: CGFloat,
itemBorderColor: UIColor?,
focusedItemBorderWidth: CGFloat,
focusedItemBorderColor: UIColor?,
focusedItemOverlayColor: UIColor?
) {
self.cornerRadius = cornerRadius
self.itemBorderWidth = itemBorderWidth
self.itemBorderColor = itemBorderColor
self.focusedItemBorderWidth = focusedItemBorderWidth
self.focusedItemBorderColor = focusedItemBorderColor
self.focusedItemOverlayColor = focusedItemOverlayColor
}
}
public class GalleryRailCellView: UIView {
weak var delegate: GalleryRailCellViewDelegate?
private let configuration: GalleryRailCellConfiguration
public init(configuration: GalleryRailCellConfiguration = .empty) {
self.configuration = configuration
super.init(frame: .zero)
layoutMargins = .zero
clipsToBounds = false
addSubview(contentContainer)
contentContainer.autoPinEdgesToSuperviewMargins()
contentContainer.layer.cornerRadius = configuration.cornerRadius
dimmerView.layer.cornerRadius = configuration.cornerRadius
addSubview(dimmerView)
dimmerView.autoPinEdges(toEdgesOf: contentContainer)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap(sender:)))
addGestureRecognizer(tapGesture)
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Actions
@objc
private func didTap(sender: UITapGestureRecognizer) {
delegate?.didTapGalleryRailCellView(self)
}
var item: GalleryRailItem?
func configure(item: GalleryRailItem, delegate: GalleryRailCellViewDelegate) {
self.item = item
self.delegate = delegate
for view in contentContainer.subviews {
view.removeFromSuperview()
}
let itemView = item.buildRailItemView()
contentContainer.addSubview(itemView)
itemView.autoPinEdgesToSuperviewEdges()
}
// MARK: Selected
var isCellFocused: Bool = false {
didSet {
let borderWidth = isCellFocused ? configuration.focusedItemBorderWidth : configuration.itemBorderWidth
dimmerView.layer.borderWidth = borderWidth
let borderColor = isCellFocused ? configuration.focusedItemBorderColor : configuration.itemBorderColor
dimmerView.layer.borderColor = borderColor?.cgColor
let dimmerColor = isCellFocused ? configuration.focusedItemOverlayColor : nil
dimmerView.backgroundColor = dimmerColor
}
}
// MARK: Subview Helpers
private let contentContainer: UIView = {
let view = UIView()
view.autoPinToSquareAspectRatio()
view.clipsToBounds = true
return view
}()
private let dimmerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
}
public protocol GalleryRailViewDelegate: AnyObject {
func galleryRailView(_ galleryRailView: GalleryRailView, didTapItem imageRailItem: GalleryRailItem)
}
public class GalleryRailView: UIView, GalleryRailCellViewDelegate {
public weak var delegate: GalleryRailViewDelegate?
private(set) var cellViews: [GalleryRailCellView] = []
public var isScrollEnabled: Bool {
get { scrollView.isScrollEnabled }
set { scrollView.isScrollEnabled = newValue }
}
public var itemSize: CGFloat = 40 {
didSet {
if let stackViewHeightConstraint {
stackViewHeightConstraint.constant = itemSize
}
setNeedsLayout()
}
}
// MARK: UIView
override init(frame: CGRect) {
super.init(frame: frame)
clipsToBounds = false
preservesSuperviewLayoutMargins = true
addSubview(scrollView)
// Constrain width to view and not layout guide because as of iOS 16.4
// UIStackView, that GalleryRailView is placed in, was messing with view's layout margins.
scrollView.autoPinWidthToSuperview()
// Constrain height to margins because view controller adjusts those to control view spacing.
scrollView.autoPinHeightToSuperviewMargins()
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func layoutSubviews() {
super.layoutSubviews()
updateScrollViewContentInsetsIfNecessary()
scrollToFocusedCell(animated: false)
}
public func configureCellViews(
itemProvider: GalleryRailItemProvider,
focusedItem: GalleryRailItem,
cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView,
animated: Bool = true
) {
let areRailItemsIdentical = { (lhs: [GalleryRailItem], rhs: [GalleryRailItem]) -> Bool in
guard lhs.count == rhs.count else {
return false
}
for (index, element) in lhs.enumerated() {
guard element.isEqualToGalleryRailItem(rhs[index]) else {
return false
}
}
return true
}
let currentRailItems = cellViews.compactMap { $0.item }
if itemProvider === self.itemProvider, areRailItemsIdentical(itemProvider.railItems, currentRailItems) {
updateFocusedItem(focusedItem, animated: animated)
return
}
self.itemProvider = itemProvider
if let stackView {
stackView.removeFromSuperview()
}
cellViews = buildCellViews(items: itemProvider.railItems, cellViewBuilder: cellViewBuilder)
let stackView = installNewStackView(arrangedSubviews: cellViews)
stackViewHeightConstraint = stackView.autoSetDimension(.height, toSize: itemSize)
stackView.layoutIfNeeded()
self.stackView = stackView
UIView.performWithoutAnimation {
self.layoutIfNeeded()
}
updateFocusedItem(focusedItem, animated: animated)
}
// MARK: GalleryRailCellViewDelegate
func didTapGalleryRailCellView(_ galleryRailCellView: GalleryRailCellView) {
guard let item = galleryRailCellView.item else {
owsFailDebug("item was unexpectedly nil")
return
}
delegate?.galleryRailView(self, didTapItem: item)
}
// MARK: Subview Helpers
private var itemProvider: GalleryRailItemProvider?
private let scrollView: UIScrollView = {
let scrollView = UIScrollView()
scrollView.isScrollEnabled = true
scrollView.clipsToBounds = false
scrollView.layoutMargins = .zero
scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
return scrollView
}()
private var lastKnownScrollViewWidth: CGFloat = 0
private var stackView: UIStackView?
private func installNewStackView(arrangedSubviews: [UIView]) -> UIStackView {
let stackView = UIStackView(arrangedSubviews: arrangedSubviews)
stackView.axis = .horizontal
stackView.spacing = 4
scrollView.addSubview(stackView)
addConstraints([
NSLayoutConstraint(
item: stackView, attribute: .leading, relatedBy: .equal,
toItem: scrollView.contentLayoutGuide, attribute: .leading, multiplier: 1, constant: 0
),
NSLayoutConstraint(
item: stackView, attribute: .top, relatedBy: .equal,
toItem: scrollView.contentLayoutGuide, attribute: .top, multiplier: 1, constant: 0
),
NSLayoutConstraint(
item: stackView, attribute: .trailing, relatedBy: .equal,
toItem: scrollView.contentLayoutGuide, attribute: .trailing, multiplier: 1, constant: 0
),
NSLayoutConstraint(
item: stackView, attribute: .bottom, relatedBy: .equal,
toItem: scrollView.contentLayoutGuide, attribute: .bottom, multiplier: 1, constant: 0
),
NSLayoutConstraint(
item: stackView, attribute: .height, relatedBy: .equal,
toItem: scrollView, attribute: .height, multiplier: 1, constant: 0
)
])
return stackView
}
private var stackViewHeightConstraint: NSLayoutConstraint?
private func buildCellViews(
items: [GalleryRailItem],
cellViewBuilder: (GalleryRailItem) -> GalleryRailCellView
) -> [GalleryRailCellView] {
return items.map { item in
let cellView = cellViewBuilder(item)
cellView.configure(item: item, delegate: self)
return cellView
}
}
enum ScrollFocusMode {
case keepCentered, keepWithinBounds
}
var scrollFocusMode: ScrollFocusMode = .keepCentered {
didSet {
if oldValue != scrollFocusMode {
setNeedsUpdateScrollViewContentInsets()
updateScrollViewContentInsetsIfNecessary()
}
}
}
private func setNeedsUpdateScrollViewContentInsets() {
lastKnownScrollViewWidth = 0
}
private func updateScrollViewContentInsetsIfNecessary() {
guard let stackView, stackView.frame.width > 0, scrollView.frame.width > 0 else { return }
let scrollViewWidth = scrollView.frame.width
guard scrollViewWidth != lastKnownScrollViewWidth else { return }
switch scrollFocusMode {
case .keepCentered:
// Shrink scroll view viewport area to a size of one cell view, centered horizontally.
let horizontalContentInset = 0.5 * (scrollViewWidth - itemSize)
scrollView.contentInset.left = horizontalContentInset
scrollView.contentInset.right = horizontalContentInset
case .keepWithinBounds:
scrollView.contentInset.left = 0
scrollView.contentInset.right = 0
}
lastKnownScrollViewWidth = scrollViewWidth
}
private func updateFocusedItem(_ focusedItem: GalleryRailItem, animated: Bool) {
guard !cellViews.isEmpty else { return }
cellViews.forEach { cellView in
if let item = cellView.item, item.isEqualToGalleryRailItem(focusedItem) {
cellView.isCellFocused = true
} else {
cellView.isCellFocused = false
}
}
scrollToFocusedCell(animated: animated)
}
private func scrollToFocusedCell(animated: Bool) {
guard let focusedCell = cellViews.first(where: { $0.isCellFocused }) else { return }
let cellFrame = focusedCell.convert(focusedCell.bounds, to: scrollView)
scrollView.scrollRectToVisible(cellFrame, animated: animated)
}
}