Update styling for mentions picker in chat.

Glass background on iOS 26.
This commit is contained in:
Igor Solomennikov
2025-11-04 16:52:11 -08:00
committed by GitHub
parent 16e3618ba9
commit a0d4874a6f
3 changed files with 287 additions and 344 deletions

View File

@@ -153,7 +153,6 @@
3402AA80271D9E180084CBAE /* TappableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95532271B510400B05242 /* TappableView.swift */; };
3402AA82271D9E180084CBAE /* DisappearingTimerConfigurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9551F271B510400B05242 /* DisappearingTimerConfigurationView.swift */; };
3402AA83271D9E180084CBAE /* OWSFlatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95511271B510400B05242 /* OWSFlatButton.swift */; };
3402AA86271D9E180084CBAE /* ResizingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95531271B510400B05242 /* ResizingScrollView.swift */; };
3402AA87271D9E180084CBAE /* CVCellMeasurement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95523271B510400B05242 /* CVCellMeasurement.swift */; };
3402AA88271D9E180084CBAE /* ContactCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A9550B271B510400B05242 /* ContactCellView.swift */; };
3402AA89271D9E180084CBAE /* ImageEditorPinchGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A95558271B510400B05242 /* ImageEditorPinchGestureRecognizer.swift */; };
@@ -4222,7 +4221,6 @@
34A9552D271B510400B05242 /* BodyRangesTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BodyRangesTextView.swift; sourceTree = "<group>"; };
34A9552F271B510400B05242 /* LoopingVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopingVideoView.swift; sourceTree = "<group>"; };
34A95530271B510400B05242 /* OWSButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSButton.swift; sourceTree = "<group>"; };
34A95531271B510400B05242 /* ResizingScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResizingScrollView.swift; sourceTree = "<group>"; };
34A95532271B510400B05242 /* TappableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TappableView.swift; sourceTree = "<group>"; };
34A95535271B510400B05242 /* OWSBubbleShapeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSBubbleShapeView.swift; sourceTree = "<group>"; };
34A95536271B510400B05242 /* ContactTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactTableViewCell.swift; sourceTree = "<group>"; };
@@ -8500,7 +8498,6 @@
32FAB9292727A57100FB76A6 /* PrimaryImageView.swift */,
B92E76AA2B6871520095C4DF /* ProfileDetailLabel.swift */,
45A6DAD51EBBF85500893231 /* ReminderView.swift */,
34A95531271B510400B05242 /* ResizingScrollView.swift */,
763D7DDA27E155ED002EA7E6 /* RoundMediaButton.swift */,
766BCA7C29FB049400046016 /* RTLEnabledCollectionViewFlowLayout.swift */,
34A95509271B510400B05242 /* TappableStackView.swift */,
@@ -16640,7 +16637,6 @@
F9B652BC28D514E6006914CA /* RecipientPickerViewController.swift in Sources */,
34ACA7F62733183000E47AD4 /* RegistrationValues.swift in Sources */,
88B986FA28807EEA00F8C74D /* ReminderView.swift in Sources */,
3402AA86271D9E180084CBAE /* ResizingScrollView.swift in Sources */,
7628DDBF2807505D009AA53D /* RotationControl.swift in Sources */,
763D7DDB27E155ED002EA7E6 /* RoundMediaButton.swift in Sources */,
766BCA7D29FB049400046016 /* RTLEnabledCollectionViewFlowLayout.swift in Sources */,

View File

@@ -14,18 +14,6 @@ public enum MentionPickerStyle {
}
class MentionPicker: UIView {
private let tableView = UITableView()
private let hairlineView = UIView()
private let resizingScrollView = ResizingScrollView<UITableView>()
private var blurView: UIVisualEffectView?
let mentionableUsers: [MentionableUser]
struct MentionableUser {
let address: SignalServiceAddress
let displayName: String
}
lazy private(set) var filteredMentionableUsers = mentionableUsers
typealias Style = MentionPickerStyle
@@ -62,83 +50,250 @@ class MentionPicker: UIView {
super.init(frame: .zero)
backgroundColor = .clear
layoutMargins = .zero
let useVisualEffectViewBackground: Bool
let useGlassBackground: Bool
switch style {
case .composingAttachment:
overrideUserInterfaceStyle = .dark
tableView.backgroundColor = UIColor.ows_gray95
useVisualEffectViewBackground = false
useGlassBackground = false
case .groupReply:
overrideUserInterfaceStyle = .dark
useVisualEffectViewBackground = true
useGlassBackground = false
case .default:
useVisualEffectViewBackground = true
if #available(iOS 26, *), BuildFlags.iOS26SDKIsAvailable {
useGlassBackground = true
} else {
useGlassBackground = false
}
}
if useVisualEffectViewBackground {
var backgroundView: UIVisualEffectView?
#if compiler(>=6.2)
// Glass background.
if #available(iOS 26, *), useGlassBackground {
let glassEffect = UIGlassEffect(style: .regular)
// Copy from ConversationInputToolbar.
glassEffect.tintColor = UIColor { traitCollection in
if traitCollection.userInterfaceStyle == .dark {
return UIColor(white: 0, alpha: 0.2)
}
return UIColor(white: 1, alpha: 0.12)
}
backgroundView = UIVisualEffectView(effect: glassEffect)
backgroundView?.clipsToBounds = true
backgroundView?.cornerConfiguration = .uniformCorners(radius: .fixed(34))
tableView.cornerConfiguration = .uniformCorners(radius: .fixed(34))
tableView.contentInset.top = 10
tableView.contentInset.bottom = 10
directionalLayoutMargins = .init(hMargin: OWSTableViewController2.cellHInnerMargin, vMargin: 0)
}
#endif
// Blur background.
if backgroundView == nil {
let forceDarkTheme = overrideUserInterfaceStyle == .dark
if UIAccessibility.isReduceTransparencyEnabled {
tableView.backgroundColor = forceDarkTheme
? Theme.darkThemeBackgroundColor
: Theme.backgroundColor
} else {
backgroundView = UIVisualEffectView(
effect: forceDarkTheme ? Theme.darkThemeBarBlurEffect : Theme.barBlurEffect
)
}
}
if let backgroundView {
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addSubview(backgroundView)
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
}
addSubview(tableView)
tableView.autoPinEdgesToSuperviewEdges()
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.topAnchor.constraint(equalTo: topAnchor),
tableView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
tableView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
tableView.delegate = self
tableView.dataSource = self
tableView.estimatedRowHeight = cellHeight
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.isScrollEnabled = false
// Hairline for when there's no glass background.
if !useGlassBackground {
let hairlineView = UIView()
switch style {
case .composingAttachment:
hairlineView.backgroundColor = .ows_gray65
tableView.register(MentionableUserCell.self, forCellReuseIdentifier: MentionableUserCell.reuseIdentifier)
case .groupReply, .default:
hairlineView.backgroundColor = UIColor { traitCollection in
traitCollection.userInterfaceStyle == .dark ? UIColor.ows_gray75 : UIColor.ows_gray05
}
}
hairlineView.translatesAutoresizingMaskIntoConstraints = false
addSubview(hairlineView)
NSLayoutConstraint.activate([
hairlineView.topAnchor.constraint(equalTo: tableView.topAnchor),
hairlineView.leadingAnchor.constraint(equalTo: leadingAnchor),
hairlineView.trailingAnchor.constraint(equalTo: trailingAnchor),
hairlineView.heightAnchor.constraint(equalToConstant: 1),
])
}
resizingScrollView.resizingView = tableView
resizingScrollView.delegate = self
addSubview(resizingScrollView)
resizingScrollView.autoPinEdgesToSuperviewEdges()
tableView.autoMatch(.height, to: .height, of: resizingScrollView)
NotificationCenter.default.addObserver(
self,
selector: #selector(applyTheme),
name: .themeDidChange,
object: nil
)
addSubview(hairlineView)
hairlineView.autoPinWidthToSuperview()
hairlineView.autoPinEdge(.top, to: .top, of: tableView)
hairlineView.autoSetDimension(.height, toSize: 1)
applyTheme()
// Setup height constraint for the container view.
heightConstraint = tableView.heightAnchor.constraint(equalToConstant: 0)
heightConstraint.priority = .defaultHigh
heightConstraint.isActive = true
updateHeightConstraint(to: minimumHeight())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override var center: CGPoint {
didSet {
// iOS 15 layout changes introduce a crash where we re-enterantly perform
// layout. A stopgap candidate fix may be to only refresh height constraints
// if the center changes *significantly* (rather than any change at all)
if !oldValue.fuzzyEquals(center, tolerance: 0.1) {
resizingScrollView.refreshHeightConstraints()
}
override func didMoveToSuperview() {
super.didMoveToSuperview()
updateHeightIfNeeded()
DispatchQueue.main.async {
self.updateHeightIfNeeded()
}
}
private var cellHeight: CGFloat { MentionableUserCell.cellHeight }
private var minimumTableHeight: CGFloat {
let minimumTableHeight = filteredMentionableUsers.count < 5
// MARK: - Layout
private lazy var tableView: UITableView = {
let tableView = UITableView(frame: .zero, style: .plain)
tableView.delegate = self
tableView.dataSource = self
tableView.estimatedRowHeight = MentionableUserCell.cellHeight
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.backgroundColor = .clear
tableView.register(MentionableUserCell.self, forCellReuseIdentifier: MentionableUserCell.reuseIdentifier)
return tableView
}()
private var currentHeight: CGFloat = 0
private var heightConstraint: NSLayoutConstraint!
private var isUpdatingHeight = false
private var isExpanded = false
private func updateHeightConstraint(to height: CGFloat) {
let constrainedHeight = height.clamp(minimumHeight(), maximumHeight())
guard constrainedHeight != currentHeight else { return }
heightConstraint.constant = constrainedHeight
currentHeight = constrainedHeight
isUpdatingHeight = true
UIView.animate(
withDuration: 0.25,
animations: {
self.superview?.layoutIfNeeded()
}, completion: { _ in
self.isUpdatingHeight = false
self.lastContentOffset = self.tableView.contentOffset.y
}
)
}
func updateHeightIfNeeded() {
let targetHeight = isExpanded ? maximumHeight() : minimumHeight()
updateHeightConstraint(to: targetHeight)
}
private func expandTableView() {
guard !isExpanded else { return }
isExpanded = true
let targetHeight = maximumHeight()
updateHeightConstraint(to: targetHeight)
}
private func collapseTableView() {
guard isExpanded else { return }
isExpanded = false
let targetHeight = minimumHeight()
updateHeightConstraint(to: targetHeight)
}
private func minimumHeight() -> CGFloat {
let cellHeight = MentionableUserCell.cellHeight
let minimumHeight = filteredMentionableUsers.count < 5
? CGFloat(filteredMentionableUsers.count) * cellHeight
: 4.5 * cellHeight
return min(minimumTableHeight, maximumTableHeight)
return minimumHeight + tableView.contentInset.totalHeight
}
// The way this class does sizing needs to be redone. In short, it relies on oversizing
// itself to the screen height then have the superview size itself to that screen height
// and THEN it can compute its own height correctly.
// For now, just avoid re-entrancy that comes from the fact that maximumTableHeight is
// is called from ResizingScrollView.layoutSubviews but itself calls layoutIfNeeded.
private var layoutReentrancy = false
private var maximumTableHeight: CGFloat {
guard let superview = superview else { return CurrentAppContext().frame.height }
if !layoutReentrancy {
layoutReentrancy = true
superview.layoutIfNeeded()
layoutReentrancy = false
private func maximumHeight() -> CGFloat {
var maximumContainerHeight = 0.5 * CurrentAppContext().frame.height
if let superview, frame.size.height > 0 {
maximumContainerHeight = frame.maxY - superview.safeAreaInsets.top
}
let maximumCellHeight = CGFloat(filteredMentionableUsers.count) * cellHeight
let maximumContainerHeight = superview.height - (superview.height - frame.maxY) - superview.safeAreaInsets.top
return min(maximumCellHeight, maximumContainerHeight)
let maximumContentHeight = CGFloat(filteredMentionableUsers.count) * MentionableUserCell.cellHeight + tableView.contentInset.totalHeight
return min(maximumContentHeight, maximumContainerHeight)
}
// MARK: - Scroll Handling
private var lastContentOffset: CGFloat = 0
private func handleScroll(_ scrollView: UIScrollView) {
guard !isUpdatingHeight else { return }
let currentOffset = scrollView.contentOffset.y
let offsetDifference = currentOffset - lastContentOffset
let scrollThreshold: CGFloat = 40
guard abs(offsetDifference) > scrollThreshold else { return }
if offsetDifference > 0, !isExpanded {
expandTableView()
} else if isExpanded, currentOffset < -(scrollThreshold + tableView.contentInset.top) {
collapseTableView()
}
lastContentOffset = currentOffset
}
// MARK: - User Matching
struct MentionableUser {
let address: SignalServiceAddress
let displayName: String
}
private let mentionableUsers: [MentionableUser]
lazy private(set) var filteredMentionableUsers = mentionableUsers
/// Used to update the filtered list of users for display.
/// If the mention text results in no users remaining, returns
/// false so the caller can dismiss the picker.
@@ -169,65 +324,14 @@ class MentionPicker: UIView {
tableView.reloadData()
resizingScrollView.refreshHeightConstraints()
return true
}
// MARK: -
@objc
private func applyTheme() {
switch style {
case .composingAttachment:
tableView.backgroundColor = UIColor.ows_gray95
hairlineView.backgroundColor = .ows_gray65
case .groupReply:
blurView?.removeFromSuperview()
blurView = nil
if UIAccessibility.isReduceTransparencyEnabled {
tableView.backgroundColor = Theme.darkThemeBackgroundColor
} else {
tableView.backgroundColor = .clear
let blurView = UIVisualEffectView(effect: Theme.darkThemeBarBlurEffect)
self.blurView = blurView
insertSubview(blurView, belowSubview: tableView)
blurView.autoPinEdgesToSuperviewEdges()
}
hairlineView.backgroundColor = .ows_gray75
case .`default`:
blurView?.removeFromSuperview()
blurView = nil
if UIAccessibility.isReduceTransparencyEnabled {
tableView.backgroundColor = Theme.backgroundColor
} else {
tableView.backgroundColor = .clear
let blurView = UIVisualEffectView(effect: Theme.barBlurEffect)
self.blurView = blurView
insertSubview(blurView, belowSubview: tableView)
blurView.autoPinEdgesToSuperviewEdges()
}
hairlineView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray05
}
tableView.reloadData()
}
}
extension MentionPicker: ResizingScrollViewDelegate {
var resizingViewMinimumHeight: CGFloat { minimumTableHeight }
var resizingViewMaximumHeight: CGFloat { maximumTableHeight }
}
// MARK: - Keyboard Interaction
extension MentionPicker {
func highlightAndScrollToRow(_ row: Int, animated: Bool = true) {
guard row >= 0 && row < filteredMentionableUsers.count else { return }
@@ -279,8 +383,18 @@ extension MentionPicker {
// MARK: -
extension MentionPicker: UITableViewDelegate, UITableViewDataSource {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard scrollView.isTracking else { return }
handleScroll(scrollView)
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
lastContentOffset = scrollView.contentOffset.y
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return filteredMentionableUsers.count
return filteredMentionableUsers.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
@@ -310,55 +424,79 @@ extension MentionPicker: UITableViewDelegate, UITableViewDataSource {
}
}
// MARK: -
private class MentionableUserCell: UITableViewCell {
static let reuseIdentifier = "MentionPickerCell"
static let avatarSizeClass: ConversationAvatarView.Configuration.SizeClass = .thirtySix
static let vSpacing: CGFloat = 10
static let hSpacing: CGFloat = 12
private static let avatarSizeClass: ConversationAvatarView.Configuration.SizeClass = .thirtySix
static var cellHeight: CGFloat {
let cell = MentionableUserCell()
cell.displayNameLabel.text = LocalizationNotNeeded("size")
cell.displayNameLabel.sizeToFit()
return max(CGFloat(avatarSizeClass.size.height), ceil(cell.displayNameLabel.height)) + vSpacing * 2
let cellSize = cell.systemLayoutSizeFitting(
CGSize(width: 300, height: CGFloat.greatestFiniteMagnitude),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
return cellSize.height
}
let displayNameLabel = UILabel()
let avatarView = ConversationAvatarView(
private let displayNameLabel: UILabel = {
let label = UILabel()
label.textColor = .Signal.label
label.font = .dynamicTypeBody
return label
}()
private let avatarView = ConversationAvatarView(
sizeClass: MentionableUserCell.avatarSizeClass,
localUserDisplayMode: .asUser,
useAutolayout: true)
useAutolayout: true
)
private static let vMargin: CGFloat = 10
private static let hMargin: CGFloat = 2 * vMargin
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
backgroundColor = .clear
selectedBackgroundView = UIView()
let avatarContainer = UIView()
avatarContainer.addSubview(avatarView)
avatarView.autoPinWidthToSuperview()
avatarView.autoVCenterInSuperview()
avatarView.autoMatch(.height, to: .height, of: avatarContainer, withOffset: 0, relation: .lessThanOrEqual)
avatarView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
avatarView.widthAnchor.constraint(equalToConstant: Self.avatarSizeClass.size.width),
avatarView.heightAnchor.constraint(equalToConstant: Self.avatarSizeClass.size.height),
displayNameLabel.font = .dynamicTypeSubheadline
let stackView = UIStackView(arrangedSubviews: [
avatarContainer,
displayNameLabel,
UIView.hStretchingSpacer()
avatarView.topAnchor.constraint(greaterThanOrEqualTo: avatarContainer.topAnchor),
avatarView.centerYAnchor.constraint(equalTo: avatarContainer.centerYAnchor),
avatarView.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
avatarView.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
])
stackView.axis = .horizontal
stackView.spacing = Self.hSpacing
stackView.isUserInteractionEnabled = false
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: Self.vSpacing, left: Self.hSpacing, bottom: Self.vSpacing, right: Self.hSpacing)
let stackView = UIStackView(arrangedSubviews: [ avatarContainer, displayNameLabel ])
stackView.axis = .horizontal
stackView.spacing = 12
stackView.isUserInteractionEnabled = false
stackView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stackView)
stackView.autoPinHeightToSuperview()
stackView.autoPinEdge(toSuperviewSafeArea: .leading)
stackView.autoPinEdge(toSuperviewSafeArea: .trailing)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Self.vMargin),
stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Self.hMargin),
stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Self.hMargin),
stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Self.vMargin),
])
}
override func updateConfiguration(using state: UICellConfigurationState) {
var configuration = UIBackgroundConfiguration.clear()
if state.isSelected || state.isHighlighted {
configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
configuration.backgroundInsets = .init(hMargin: 0.5 * Self.hMargin, vMargin: 0)
configuration.cornerRadius = 50
}
backgroundConfiguration = configuration
}
required init?(coder: NSCoder) {
@@ -366,15 +504,6 @@ private class MentionableUserCell: UITableViewCell {
}
func configure(with mentionableUser: MentionPicker.MentionableUser, style: MentionPicker.Style) {
switch style {
case .composingAttachment, .groupReply:
displayNameLabel.textColor = Theme.darkThemePrimaryColor
selectedBackgroundView?.backgroundColor = UIColor.white.withAlphaComponent(0.2)
case .`default`:
displayNameLabel.textColor = Theme.primaryTextColor
selectedBackgroundView?.backgroundColor = Theme.tableCell2SelectedBackgroundColor
}
displayNameLabel.text = mentionableUser.displayName
avatarView.updateWithSneakyTransactionIfNecessary { configuration in

View File

@@ -1,182 +0,0 @@
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public protocol ResizingView: UIView {
var contentOffset: CGPoint { get set }
var contentSize: CGSize { get }
}
extension UIScrollView: ResizingView {}
public protocol ResizingScrollViewDelegate: AnyObject {
var resizingViewMinimumHeight: CGFloat { get }
var resizingViewMaximumHeight: CGFloat { get }
}
public class ResizingScrollView<ResizingViewType: ResizingView>: UIView, UIScrollViewDelegate {
public weak var resizingView: ResizingViewType? {
didSet {
oldValue?.removeGestureRecognizer(gestureScrollView.panGestureRecognizer)
resizingView?.addGestureRecognizer(gestureScrollView.panGestureRecognizer)
refreshHeightConstraints()
}
}
public weak var delegate: ResizingScrollViewDelegate? {
didSet { refreshHeightConstraints() }
}
// We utilize this scroll view *only* for it's panGestureRecognizer. No
// interaction happens with the scrollView directly, instead we translate
// its movement onto the view that is being resized. Doing this allows us
// to get all the properties around tracking, bouncing, and decelerating
// of an actual scrollView in contexts that aren't achievable with a
// scrollView directly.
private let gestureScrollView = UIScrollView()
private lazy var heightConstraint = gestureScrollView.autoSetDimension(.height, toSize: 0, relation: .lessThanOrEqual)
public init() {
super.init(frame: .zero)
isUserInteractionEnabled = false
gestureScrollView.delegate = self
addSubview(gestureScrollView)
gestureScrollView.autoPinEdgesToSuperviewEdges()
NSLayoutConstraint.autoSetPriority(.defaultHigh) {
gestureScrollView.autoSetDimension(.height, toSize: .greatestFiniteMagnitude)
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var current: State = .zero
private struct State: Equatable {
let minimumHeight: CGFloat
let maximumHeight: CGFloat
let contentSize: CGSize
static var zero: Self { .init(minimumHeight: 0, maximumHeight: 0, contentSize: .zero) }
}
/// Call to notify the resizing scroll view that it's minimum and/or maximum
/// bound has changed.
public func refreshHeightConstraints() {
guard let resizingView = resizingView, let delegate = delegate else { return }
let new = State(
minimumHeight: delegate.resizingViewMinimumHeight,
maximumHeight: delegate.resizingViewMaximumHeight,
contentSize: resizingView.contentSize
)
guard new.maximumHeight >= new.minimumHeight else {
return owsFailDebug("Unexpectedly had a minimum height that is larger than the maximum height")
}
guard new.maximumHeight >= 0, new.minimumHeight >= 0 else {
return owsFailDebug("Unexpectedly had a negative height value")
}
// Our minimum and maximum possible height could be changed at any
// point by our delegate. When it does change, we need to adjust
// the properties of the gestureScrollView to reflect that. For
// example, the min/max bounds may change when a keyboard is
// presented. It's the delegates responsibility to notify us
// when this change occurs by calling `refreshHeightConstraints`,
// but we also do our best to proactively update since doing so
// should be cheap.
guard new != current else { return }
layoutIfNeeded()
let currentHeight = gestureScrollView.height
let currentOffset = gestureScrollView.contentOffset.y
// The inset represents the difference between the min
// and max sizes. When the scroll view's offset is less
// than 0 (in the inset range) we're resizing the view.
// When it's >= 0, we're scrolling the view.
let newInset = new.maximumHeight - new.minimumHeight
let newHeight: CGFloat
if currentHeight >= new.minimumHeight && currentHeight <= new.maximumHeight {
newHeight = currentHeight
} else if currentHeight < new.minimumHeight {
newHeight = new.minimumHeight
} else if currentHeight > new.maximumHeight {
newHeight = new.maximumHeight
} else if new.maximumHeight > current.maximumHeight {
// If the amount of space we can take up is growing,
// grow to fill the new space.
newHeight = min(currentHeight + (new.maximumHeight - current.maximumHeight), new.maximumHeight)
} else {
// If the amount of space we can take up is shrinking,
// shrink to accommodate the new space.
newHeight = max(currentHeight - (current.maximumHeight - new.maximumHeight), new.minimumHeight)
}
let newOffset: CGFloat
if newHeight < new.maximumHeight {
// If we're less than the maximum height, our
// offset is always a representation of the
// difference between the maximum height and
// our current height.
newOffset = newHeight - new.maximumHeight
} else {
// If we're above the maximum height, the offset
// represents the current scroll position.
newOffset = max(0, currentOffset)
}
heightConstraint.constant = newHeight
gestureScrollView.contentSize = new.contentSize
gestureScrollView.contentInset.top = newInset
gestureScrollView.bounds.origin.y = newOffset
current = new
}
public override func layoutSubviews() {
refreshHeightConstraints()
super.layoutSubviews()
}
public func scrollViewDidScroll(_ scrollView: UIScrollView) {
owsAssertDebug(gestureScrollView == scrollView)
guard let resizingView = resizingView, let delegate = delegate else { return }
// Whenever the gesture scrollView scrolls, we need to update:
// * our height, which the resizing view will reference
// * the offset of the resizing view.
//
// If our offset is less than 0, we're in the "inset" range.
// This range represents the difference between the min and
// max height and is the space in which we should resize rather
// than scroll.
//
// If our offset is >=0, the view should always be the max height
// and our offset can be directly translated to the resizing view.
if scrollView.contentOffset.y < 0 {
let difference = scrollView.contentInset.top + scrollView.contentOffset.y
if difference < 0 {
heightConstraint.constant = delegate.resizingViewMinimumHeight
resizingView.contentOffset.y = difference
} else {
heightConstraint.constant = delegate.resizingViewMinimumHeight + difference
resizingView.contentOffset = .zero
}
} else {
heightConstraint.constant = delegate.resizingViewMaximumHeight
resizingView.contentOffset = scrollView.contentOffset
}
}
}