Signal-iOS/Signal/ConversationView/Components/CVComponentCollapseSet.swift

459 lines
17 KiB
Swift

//
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
var componentKey: CVComponentKey { .collapseSet }
var cellReuseIdentifier: CVCellReuseIdentifier { .collapseSet }
let isDedicatedCell = true
private let collapseSet: CVComponentState.CollapseSet
init(itemModel: CVItemModel, collapseSet: CVComponentState.CollapseSet) {
self.collapseSet = collapseSet
super.init(itemModel: itemModel)
}
// MARK: - CVRootComponent
func configureCellRootComponent(
cellView: UIView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
messageSwipeActionState: CVMessageSwipeActionState,
componentView: CVComponentView,
) {
Self.configureCellRootComponent(
rootComponent: self,
cellView: cellView,
cellMeasurement: cellMeasurement,
componentDelegate: componentDelegate,
componentView: componentView,
)
}
func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewCollapseSet()
}
override func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
guard let componentView = componentView as? CVComponentViewCollapseSet else {
owsFailDebug("Unexpected componentView.")
return nil
}
return componentView.wallpaperBlurView
}
func configureForRendering(
componentView: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
) {
guard let componentView = componentView as? CVComponentViewCollapseSet else {
owsFailDebug("Unexpected componentView.")
componentView.reset()
return
}
let hasWallpaper = conversationStyle.hasWallpaper
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
let isReusing = componentView.rootView.superview != nil
&& componentView.innerStack.superview != nil
&& !wallpaperModeHasChanged
if !isReusing {
componentView.reset()
}
componentView.hasWallpaper = hasWallpaper
labelConfig.applyForRendering(label: componentView.label)
chevronConfig.applyForRendering(label: componentView.chevronLabel)
if isReusing {
componentView.innerStack.configureForReuse(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
)
componentView.outerStack.configureForReuse(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
)
} else {
componentView.innerStack.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: [componentView.label, componentView.chevronContainer],
)
componentView.outerStack.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: [componentView.innerStack],
)
let bubbleView: UIView
if hasWallpaper {
let wallpaperBlurView = componentView.ensureWallpaperBlurView()
configureWallpaperBlurView(
wallpaperBlurView: wallpaperBlurView,
componentDelegate: componentDelegate,
bubbleConfig: BubbleConfiguration(
corners: .capsule(maxRadius: 16),
stroke: ConversationStyle.bubbleStroke(isDarkThemeEnabled: isDarkThemeEnabled),
),
)
bubbleView = wallpaperBlurView
} else {
let solidBackgroundView = componentView.solidBackgroundView
solidBackgroundView.layer.cornerRadius = 16
solidBackgroundView.backgroundColor = .Signal.secondaryFill
bubbleView = solidBackgroundView
}
componentView.outerStack.addSubview(bubbleView)
componentView.outerStack.sendSubviewToBack(bubbleView)
componentView.outerStack.addLayoutBlock { [innerStack = componentView.innerStack] _ in
bubbleView.frame = innerStack.frame.inset(by: Self.backgroundLayoutInsets)
}
componentView.innerStack.addLayoutBlock { [chevronContainer = componentView.chevronContainer, chevronLabel = componentView.chevronLabel] _ in
chevronLabel.bounds.size = chevronContainer.bounds.size
chevronLabel.center = CGPoint(x: chevronContainer.bounds.midX, y: chevronContainer.bounds.midY)
}
}
componentView.isShowingExpanded = collapseSet.isExpanded
componentView.chevronLabel.transform = collapseSet.isExpanded
? CGAffineTransform(rotationAngle: -.pi)
: .identity
if
hasWallpaper,
let wallpaperBlurView = componentView.wallpaperBlurView
{
wallpaperBlurView.applyLayout()
wallpaperBlurView.updateIfNecessary()
}
componentView.outerStack.isAccessibilityElement = true
componentView.outerStack.accessibilityLabel = titleString
componentView.outerStack.accessibilityTraits = .button
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
}
// MARK: - Events
override func handleTap(
sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem,
) -> Bool {
if let componentView = componentView as? CVComponentViewCollapseSet {
let wasExpanded = componentView.isShowingExpanded
let willBeExpanded = !wasExpanded
let expandedRotation: CGFloat = -.pi
let isRTL = componentView.chevronLabel.effectiveUserInterfaceLayoutDirection == .rightToLeft
let fromAngle: CGFloat
let toAngle: CGFloat
if willBeExpanded {
fromAngle = 0
toAngle = isRTL ? CGFloat.pi : -CGFloat.pi
} else {
fromAngle = expandedRotation
toAngle = isRTL ? -2 * CGFloat.pi : 0
}
componentView.isShowingExpanded = willBeExpanded
componentView.chevronLabel.transform = willBeExpanded
? CGAffineTransform(rotationAngle: expandedRotation)
: .identity
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = fromAngle
animation.toValue = toAngle
animation.duration = 0.2
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
componentView.chevronLabel.layer.add(animation, forKey: "chevronRotation")
}
componentDelegate.didTapCollapseSet(collapseSetId: interaction.uniqueId)
return true
}
// MARK: - Measurement
fileprivate static let measurementKey_outerStack = "CVComponentCollapseSet.outerStack"
fileprivate static let measurementKey_innerStack = "CVComponentCollapseSet.innerStack"
func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
let availableWidth = max(
0,
maxWidth - outerStackConfig.layoutMargins.totalWidth,
)
let chevronSize = CVText.measureLabel(config: chevronConfig, maxWidth: availableWidth)
let labelMaxWidth = max(0, availableWidth - chevronSize.width - innerStackConfig.spacing)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: labelMaxWidth)
let innerMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: [
labelSize.asManualSubviewInfo(hasFixedWidth: true),
chevronSize.asManualSubviewInfo(hasFixedSize: true),
],
)
let outerMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: [innerMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)],
)
return outerMeasurement.measuredSize
}
// MARK: - Layout
private var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 4,
layoutMargins: .zero,
)
}
private var outerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .center,
spacing: 0,
layoutMargins: UIEdgeInsets(
top: 4 + Self.labelContentInsets.top,
leading: conversationStyle.fullWidthGutterLeading + Self.labelContentInsets.leading,
bottom: 4 + Self.labelContentInsets.bottom,
trailing: conversationStyle.fullWidthGutterTrailing + Self.labelContentInsets.trailing,
),
)
}
private static var backgroundLayoutInsets: UIEdgeInsets {
UIEdgeInsets(
top: -labelContentInsets.top,
leading: -labelContentInsets.leading,
bottom: -labelContentInsets.bottom,
trailing: -labelContentInsets.trailing,
)
}
// MARK: - Content
private var labelFont: UIFont { .dynamicTypeFootnote.medium() }
private static var labelContentInsets: NSDirectionalEdgeInsets {
NSDirectionalEdgeInsets(hMargin: 14, vMargin: 5)
}
private var leadingIcon: SignalSymbol {
switch collapseSet.collapseSetType {
case .groupUpdates: return .group
case .chatUpdates: return .thread
case .callEvents: return .phone
case .timerChanges: return .timer
}
}
private var labelConfig: CVLabelConfig {
CVLabelConfig(
text: .attributedText(titleAttributedString),
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
font: labelFont,
textColor: .Signal.label,
numberOfLines: 0,
lineBreakMode: .byWordWrapping,
textAlignment: .center,
)
}
private var titleAttributedString: NSAttributedString {
let labelText = summaryLabel(
count: collapseSet.collapsedInteractions.count,
type: collapseSet.collapseSetType,
finalTimerDescription: collapseSet.finalTimerDescription,
)
let nbsp = SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue
let result = NSMutableAttributedString()
result.append(leadingIcon.attributedString(
for: .footnote,
clamped: false,
attributes: [.foregroundColor: UIColor.Signal.label],
))
result.append(NSAttributedString(
string: "\(nbsp)\(labelText)",
attributes: [
.font: labelFont,
.foregroundColor: UIColor.Signal.label,
],
))
return result
}
private var chevronConfig: CVLabelConfig {
CVLabelConfig(
text: .attributedText(chevronAttributedString),
displayConfig: .forUnstyledText(font: labelFont, textColor: .Signal.label),
font: labelFont,
textColor: .Signal.label,
numberOfLines: 1,
lineBreakMode: .byClipping,
textAlignment: .center,
)
}
private var chevronAttributedString: NSAttributedString {
SignalSymbol.chevronDown.attributedString(
for: .footnote,
clamped: false,
attributes: [.foregroundColor: UIColor.Signal.label],
)
}
private var titleString: String {
summaryLabel(
count: collapseSet.collapsedInteractions.count,
type: collapseSet.collapseSetType,
finalTimerDescription: collapseSet.finalTimerDescription,
)
}
private func accessibilityHint(isExpanded: Bool) -> String {
isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
}
private func summaryLabel(
count: Int,
type: CollapseSetInteraction.MessagesType,
finalTimerDescription: String? = nil,
) -> String {
switch type {
case .groupUpdates:
return String(
format: OWSLocalizedString(
"COLLAPSE_SET_GROUP_UPDATES_%d",
tableName: "PluralAware",
comment: "Label for a collapsed group of group update events. Embeds {{number of events}}.",
),
count,
)
case .chatUpdates:
return String(
format: OWSLocalizedString(
"COLLAPSE_SET_CHAT_UPDATES_%d",
tableName: "PluralAware",
comment: "Label for a collapsed group of chat update events. Embeds {{number of events}}.",
),
count,
)
case .timerChanges:
let finalTimer: String
if let finalTimerDescription {
finalTimer = finalTimerDescription
} else {
owsFailDebug("disappearing message timer collapse set does not have final timer description")
finalTimer = ""
}
return String(
format: OWSLocalizedString(
"COLLAPSE_SET_TIMER_CHANGES_WITH_FINAL_TIMER_%d",
tableName: "PluralAware",
comment: "Label for collapsed disappearing message timer changes showing the final timer value. Embeds {{number of events}} and {{timer description}}.",
),
count,
finalTimer,
)
case .callEvents:
return String(
format: OWSLocalizedString(
"COLLAPSE_SET_CALL_EVENTS_%d",
tableName: "PluralAware",
comment: "Label for a collapsed group of call events. Embeds {{number of events}}.",
),
count,
)
}
}
// MARK: - CVComponentViewCollapseSet
class CVComponentViewCollapseSet: NSObject, CVComponentView {
fileprivate let outerStack = ManualStackView(name: "collapseSet.outerStack")
fileprivate let innerStack = ManualStackView(name: "collapseSet.innerStack")
fileprivate let label = CVLabel()
fileprivate let chevronContainer = UIView()
fileprivate let chevronLabel = CVLabel()
fileprivate let solidBackgroundView = UIView()
fileprivate var isShowingExpanded = false
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
fileprivate func ensureWallpaperBlurView() -> CVWallpaperBlurView {
if let wallpaperBlurView = self.wallpaperBlurView {
return wallpaperBlurView
}
let wallpaperBlurView = CVWallpaperBlurView()
self.wallpaperBlurView = wallpaperBlurView
return wallpaperBlurView
}
fileprivate var hasWallpaper = false
var isDedicatedCellView = false
var rootView: UIView { outerStack }
override init() {
super.init()
chevronContainer.addSubview(chevronLabel)
}
func setIsCellVisible(_ isCellVisible: Bool) {}
func reset() {
label.reset()
chevronLabel.reset()
chevronLabel.transform = .identity
chevronLabel.layer.removeAnimation(forKey: "chevronRotation")
isShowingExpanded = false
solidBackgroundView.backgroundColor = nil
wallpaperBlurView?.removeFromSuperview()
wallpaperBlurView = nil
hasWallpaper = false
innerStack.reset()
outerStack.reset()
}
}
}