Signal-iOS/SignalUI/Views/GalleryRailView.swift
Igor Solomennikov 9b1e3b02f6
Fix incorrect scroll in media editor's thumbnail strip.
We need different behaviors for scrolling to the current item in the strip depending on whether the item should be centered or just visible.
2026-04-29 14:57:43 -07:00

399 lines
14 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 let focusedItemExtraPadding: CGFloat
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?,
focusedItemExtraPadding: CGFloat = 0,
) {
self.cornerRadius = cornerRadius
self.itemBorderWidth = itemBorderWidth
self.itemBorderColor = itemBorderColor
self.focusedItemBorderWidth = focusedItemBorderWidth
self.focusedItemBorderColor = focusedItemBorderColor
self.focusedItemOverlayColor = focusedItemOverlayColor
self.focusedItemExtraPadding = focusedItemExtraPadding
}
}
public class GalleryRailCellView: UIView {
weak var delegate: GalleryRailCellViewDelegate?
let configuration: GalleryRailCellConfiguration
private let contentContainer = UIView()
private let dimmerView = UIView()
public init(configuration: GalleryRailCellConfiguration = .empty) {
self.configuration = configuration
super.init(frame: .zero)
clipsToBounds = false
directionalLayoutMargins = .zero
contentContainer.clipsToBounds = true
contentContainer.layer.cornerRadius = configuration.cornerRadius
contentContainer.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentContainer)
contentContainer.autoPinEdgesToSuperviewMargins()
dimmerView.layer.cornerRadius = configuration.cornerRadius
dimmerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(dimmerView)
NSLayoutConstraint.activate([
contentContainer.widthAnchor.constraint(equalTo: contentContainer.heightAnchor),
contentContainer.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
contentContainer.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
contentContainer.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
contentContainer.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
dimmerView.topAnchor.constraint(equalTo: contentContainer.topAnchor),
dimmerView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
dimmerView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
dimmerView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor),
])
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)
}
private(set) 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()
itemView.translatesAutoresizingMaskIntoConstraints = false
contentContainer.addSubview(itemView)
NSLayoutConstraint.activate([
itemView.topAnchor.constraint(equalTo: contentContainer.topAnchor),
itemView.leadingAnchor.constraint(equalTo: contentContainer.leadingAnchor),
itemView.trailingAnchor.constraint(equalTo: contentContainer.trailingAnchor),
itemView.bottomAnchor.constraint(equalTo: contentContainer.bottomAnchor),
])
}
// 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
let horizontalMargin: CGFloat = isCellFocused ? configuration.focusedItemExtraPadding : 0
directionalLayoutMargins.leading = horizontalMargin
directionalLayoutMargins.trailing = horizontalMargin
}
}
}
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
scrollView.translatesAutoresizingMaskIntoConstraints = false
addSubview(scrollView)
NSLayoutConstraint.activate([
// 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.frameLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor),
scrollView.frameLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor),
// Constrain height to margins because view controller adjusts those to control view spacing.
scrollView.frameLayoutGuide.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
scrollView.frameLayoutGuide.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
])
}
public required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override public 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)
let heightConstraint = stackView.heightAnchor.constraint(equalToConstant: itemSize)
heightConstraint.isActive = true
stackView.layoutIfNeeded()
self.stackView = stackView
self.stackViewHeightConstraint = heightConstraint
UIView.performWithoutAnimation {
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
stackView.translatesAutoresizingMaskIntoConstraints = false
scrollView.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor),
stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor),
stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor),
])
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
}
}
public enum ScrollFocusMode {
case keepCentered
case keepWithinBounds
}
public 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
}
}
stackView?.layoutIfNeeded()
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)
switch scrollFocusMode {
case .keepCentered:
// Scroll view's "viewport" area is very narrow - a width of a single item, centered.
// But that width doesn't take into account an extra padding focused cell might have,
// which is why we have to add it to the content offset.
let extraPadding = focusedCell.configuration.focusedItemExtraPadding
let contentOffsetX = cellFrame.minX + extraPadding - scrollView.contentInset.left
scrollView.setContentOffset(.init(x: contentOffsetX, y: 0), animated: animated)
case .keepWithinBounds:
// Let scroll view handle the position.
scrollView.scrollRectToVisible(cellFrame, animated: animated)
}
}
}