mirror of
https://github.com/signalapp/Signal-iOS.git
synced 2025-12-05 01:10:41 +00:00
Update styling for mentions picker in chat.
Glass background on iOS 26.
This commit is contained in:
committed by
GitHub
parent
16e3618ba9
commit
a0d4874a6f
@@ -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 */,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user