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

182 lines
7.2 KiB
Swift

//
// 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
}
}
}