371 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|