478 lines
19 KiB
Swift
478 lines
19 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
public class CVComponentDateHeader: CVComponentBase, CVRootComponent {
|
|
|
|
public var componentKey: CVComponentKey { .dateHeader }
|
|
|
|
public var cellReuseIdentifier: CVCellReuseIdentifier {
|
|
CVCellReuseIdentifier.dateHeader
|
|
}
|
|
|
|
public let isDedicatedCell = true
|
|
|
|
struct State: Equatable {
|
|
let text: String
|
|
}
|
|
private let dateHeaderState: State
|
|
|
|
init(itemModel: CVItemModel, dateHeaderState: State) {
|
|
self.dateHeaderState = dateHeaderState
|
|
|
|
super.init(itemModel: itemModel)
|
|
}
|
|
|
|
public func configureCellRootComponent(cellView: UIView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
messageSwipeActionState: CVMessageSwipeActionState,
|
|
componentView: CVComponentView) {
|
|
Self.configureCellRootComponent(rootComponent: self,
|
|
cellView: cellView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
componentView: componentView)
|
|
}
|
|
|
|
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
|
|
CVComponentViewDateHeader()
|
|
}
|
|
|
|
public override func wallpaperBlurView(componentView: CVComponentView) -> CVWallpaperBlurView? {
|
|
guard let componentView = componentView as? CVComponentViewDateHeader else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return nil
|
|
}
|
|
return componentView.plainContentView?.wallpaperBlurView
|
|
}
|
|
|
|
public override func apply(layoutAttributes: CVCollectionViewLayoutAttributes,
|
|
componentView: CVComponentView) {
|
|
guard let componentView = componentView as? CVComponentViewDateHeader else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
guard let doubleContentView = componentView.doubleContentView else {
|
|
return
|
|
}
|
|
doubleContentView.normalView.isHidden = layoutAttributes.isStickyHeader
|
|
doubleContentView.stickyView.isHidden = !layoutAttributes.isStickyHeader
|
|
}
|
|
|
|
fileprivate struct DoubleContentView {
|
|
let normalView: UIView
|
|
let stickyView: UIView
|
|
}
|
|
|
|
public func configureForRendering(componentView: CVComponentView,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate) {
|
|
guard let componentView = componentView as? CVComponentViewDateHeader else {
|
|
owsFailDebug("Unexpected componentView.")
|
|
return
|
|
}
|
|
|
|
let themeHasChanged = isDarkThemeEnabled != componentView.isDarkThemeEnabled
|
|
let hasWallpaper = conversationStyle.hasWallpaper
|
|
let wallpaperModeHasChanged = hasWallpaper != componentView.hasWallpaper
|
|
|
|
let isReusing = (componentView.rootView.superview != nil &&
|
|
!themeHasChanged &&
|
|
!wallpaperModeHasChanged)
|
|
if !isReusing {
|
|
componentView.reset(resetReusableState: true)
|
|
}
|
|
|
|
componentView.isDarkThemeEnabled = isDarkThemeEnabled
|
|
componentView.hasWallpaper = hasWallpaper
|
|
|
|
let outerStack = componentView.outerStack
|
|
let doubleContentWrapper = componentView.doubleContentWrapper
|
|
|
|
let blurBackgroundColor: UIColor = {
|
|
if componentDelegate.isConversationPreview {
|
|
return isDarkThemeEnabled ? .ows_blackAlpha40 : .ows_whiteAlpha60
|
|
} else {
|
|
return isDarkThemeEnabled ? UIColor(rgbHex: 0x1B1B1B) : UIColor(rgbHex: 0xFAFAFA)
|
|
}
|
|
}()
|
|
|
|
if isReusing {
|
|
outerStack.configureForReuse(config: outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack)
|
|
|
|
let plainContentView = componentView.plainContentView
|
|
plainContentView?.configure(componentView: componentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
hasWallpaper: hasWallpaper,
|
|
titleLabelConfig: titleLabelConfigForPlainContentView,
|
|
innerStackConfig: innerStackConfig,
|
|
isReusing: true)
|
|
|
|
let contentViewVisualEffect = componentView.visualEffectContentView
|
|
contentViewVisualEffect?.configure(blurBackgroundColor: blurBackgroundColor,
|
|
titleLabelConfig: titleLabelConfigForVisualEffectContentView,
|
|
innerStackConfig: innerStackConfig)
|
|
} else {
|
|
outerStack.reset()
|
|
doubleContentWrapper.reset()
|
|
|
|
let contentView: UIView = {
|
|
func buildPlainContentView() -> UIView {
|
|
let contentView = componentView.ensurePlainContentView()
|
|
contentView.configure(componentView: componentView,
|
|
cellMeasurement: cellMeasurement,
|
|
componentDelegate: componentDelegate,
|
|
hasWallpaper: hasWallpaper,
|
|
titleLabelConfig: titleLabelConfigForPlainContentView,
|
|
innerStackConfig: innerStackConfig,
|
|
isReusing: false)
|
|
return contentView.rootView
|
|
}
|
|
func buildVisualEffectContentView() -> UIView {
|
|
let contentView = componentView.ensureVisualEffectContentView()
|
|
contentView.configure(blurBackgroundColor: blurBackgroundColor,
|
|
titleLabelConfig: titleLabelConfigForVisualEffectContentView,
|
|
innerStackConfig: innerStackConfig)
|
|
return contentView.rootView
|
|
}
|
|
|
|
// On iOS 26 always use `visual effect` content view for the sticky header.
|
|
if componentDelegate.isConversationPreview {
|
|
return buildVisualEffectContentView()
|
|
} else if hasWallpaper, #unavailable(iOS 26) {
|
|
return buildPlainContentView()
|
|
} else {
|
|
let plainContentView = buildPlainContentView()
|
|
let visualEffectContentView = buildVisualEffectContentView()
|
|
doubleContentWrapper.addSubviewToFillSuperviewEdges(plainContentView)
|
|
doubleContentWrapper.addSubviewToFillSuperviewEdges(visualEffectContentView)
|
|
componentView.doubleContentView = DoubleContentView(normalView: plainContentView,
|
|
stickyView: visualEffectContentView)
|
|
return doubleContentWrapper
|
|
}
|
|
}()
|
|
|
|
outerStack.configure(config: outerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviews: [ contentView ])
|
|
}
|
|
|
|
componentView.rootView.accessibilityLabel = titleLabelConfigForPlainContentView.text.accessibilityDescription
|
|
componentView.rootView.isAccessibilityElement = true
|
|
componentView.rootView.accessibilityTraits = .header
|
|
}
|
|
|
|
static func buildState(interaction: TSInteraction) -> State {
|
|
let date = Date(millisecondsSince1970: interaction.timestamp)
|
|
let text = DateUtil.formatDateHeaderForCVC(date)
|
|
return State(text: text)
|
|
}
|
|
|
|
private func titleLabelConfig(textColor: UIColor) -> CVLabelConfig {
|
|
return CVLabelConfig(
|
|
text: .text(dateHeaderState.text),
|
|
displayConfig: .forUnstyledText(font: .dynamicTypeFootnote.semibold(), textColor: textColor),
|
|
font: UIFont.dynamicTypeFootnote.semibold(),
|
|
textColor: textColor,
|
|
lineBreakMode: .byTruncatingTail,
|
|
textAlignment: .center
|
|
)
|
|
}
|
|
|
|
private var titleLabelConfigForPlainContentView: CVLabelConfig {
|
|
return titleLabelConfig(textColor: .Signal.secondaryLabel)
|
|
}
|
|
|
|
private var titleLabelConfigForVisualEffectContentView: CVLabelConfig {
|
|
let textColor: UIColor
|
|
if #available(iOS 26.0, *) {
|
|
textColor = .Signal.label
|
|
} else {
|
|
textColor = .Signal.secondaryLabel
|
|
}
|
|
return titleLabelConfig(textColor: textColor)
|
|
}
|
|
|
|
private var outerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .vertical,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: UIEdgeInsets(top: 0,
|
|
leading: conversationStyle.headerGutterLeading,
|
|
bottom: 0,
|
|
trailing: conversationStyle.headerGutterTrailing))
|
|
}
|
|
|
|
private var innerStackConfig: CVStackViewConfig {
|
|
CVStackViewConfig(axis: .vertical,
|
|
alignment: .center,
|
|
spacing: 0,
|
|
layoutMargins: UIEdgeInsets(hMargin: 10, vMargin: 4))
|
|
}
|
|
|
|
fileprivate static let measurementKey_outerStack = "CVComponentDateHeader.measurementKey_outerStack"
|
|
fileprivate static let measurementKey_innerStack = "CVComponentDateHeader.measurementKey_innerStack"
|
|
|
|
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
|
|
owsAssertDebug(maxWidth > 0)
|
|
|
|
let availableWidth = max(0, maxWidth -
|
|
(innerStackConfig.layoutMargins.totalWidth +
|
|
outerStackConfig.layoutMargins.totalWidth))
|
|
let labelSize = CVText.measureLabel(config: titleLabelConfigForPlainContentView, maxWidth: availableWidth)
|
|
|
|
let labelInfo = labelSize.asManualSubviewInfo
|
|
let innerStackMeasurement = ManualStackView.measure(config: innerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_innerStack,
|
|
subviewInfos: [ labelInfo ])
|
|
let innerStackInfo = innerStackMeasurement.measuredSize.asManualSubviewInfo
|
|
let outerStackMeasurement = ManualStackView.measure(config: outerStackConfig,
|
|
measurementBuilder: measurementBuilder,
|
|
measurementKey: Self.measurementKey_outerStack,
|
|
subviewInfos: [ innerStackInfo ],
|
|
maxWidth: maxWidth)
|
|
return outerStackMeasurement.measuredSize
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
// Used for rendering some portion of an Conversation View item.
|
|
// It could be the entire item or some part thereof.
|
|
public class CVComponentViewDateHeader: NSObject, CVComponentView {
|
|
|
|
fileprivate let outerStack = ManualStackView(name: "dateHeader.outerStackView")
|
|
fileprivate let doubleContentWrapper = ManualLayoutView(name: "dateHeader.doubleContentWrapper")
|
|
|
|
fileprivate var plainContentView: ContentViewNoVisualEffect?
|
|
fileprivate func ensurePlainContentView() -> ContentViewNoVisualEffect {
|
|
if let plainContentView {
|
|
return plainContentView
|
|
}
|
|
let plainContentView = ContentViewNoVisualEffect()
|
|
self.plainContentView = plainContentView
|
|
return plainContentView
|
|
}
|
|
|
|
fileprivate var visualEffectContentView: VisualEffectContentView?
|
|
fileprivate func ensureVisualEffectContentView() -> VisualEffectContentView {
|
|
if let visualEffectContentView {
|
|
return visualEffectContentView
|
|
}
|
|
#if compiler(>=6.2)
|
|
let visualEffectContentView: VisualEffectContentView
|
|
if #available(iOS 26.0, *) {
|
|
visualEffectContentView = ContentViewWithGlassEffect()
|
|
} else {
|
|
visualEffectContentView = ContentViewWithBlurEffect()
|
|
}
|
|
#else
|
|
let visualEffectContentView = ContentViewWithBlurEffect()
|
|
#endif
|
|
self.visualEffectContentView = visualEffectContentView
|
|
return visualEffectContentView
|
|
}
|
|
|
|
fileprivate var hasWallpaper = false
|
|
fileprivate var isDarkThemeEnabled = false
|
|
|
|
public var isDedicatedCellView = false
|
|
|
|
fileprivate var doubleContentView: DoubleContentView?
|
|
|
|
public var rootView: UIView {
|
|
outerStack
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func setIsCellVisible(_ isCellVisible: Bool) {}
|
|
|
|
public func reset() {
|
|
reset(resetReusableState: false)
|
|
}
|
|
|
|
public func reset(resetReusableState: Bool) {
|
|
owsAssertDebug(isDedicatedCellView)
|
|
|
|
plainContentView?.reset(resetReusableState: resetReusableState)
|
|
visualEffectContentView?.reset()
|
|
|
|
if resetReusableState {
|
|
outerStack.reset()
|
|
doubleContentWrapper.reset()
|
|
|
|
hasWallpaper = false
|
|
isDarkThemeEnabled = false
|
|
doubleContentView = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private protocol VisualEffectContentView {
|
|
var rootView: UIView { get }
|
|
func configure(blurBackgroundColor: UIColor, titleLabelConfig: CVLabelConfig, innerStackConfig: CVStackViewConfig)
|
|
func reset()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class ContentViewWithBlurEffect: VisualEffectContentView {
|
|
private let titleLabel = CVLabel()
|
|
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
|
|
private let blurOverlay = UIView()
|
|
private let wrapper: UIView
|
|
|
|
var rootView: UIView { wrapper }
|
|
|
|
init() {
|
|
blurView.clipsToBounds = true
|
|
blurView.contentView.addSubview(blurOverlay)
|
|
blurOverlay.autoPinEdgesToSuperviewEdges()
|
|
|
|
let wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(blurView)
|
|
let blurView = self.blurView
|
|
wrapper.addLayoutBlock { view in
|
|
blurView.layer.cornerRadius = view.frame.size.smallerAxis * 0.5
|
|
}
|
|
self.wrapper = wrapper
|
|
}
|
|
|
|
func configure(blurBackgroundColor: UIColor, titleLabelConfig: CVLabelConfig, innerStackConfig: CVStackViewConfig) {
|
|
titleLabelConfig.applyForRendering(label: titleLabel)
|
|
blurOverlay.backgroundColor = blurBackgroundColor
|
|
|
|
if titleLabel.superview == nil {
|
|
titleLabel.setContentHuggingLow()
|
|
blurView.contentView.addSubview(titleLabel)
|
|
titleLabel.autoPinEdgesToSuperviewEdges(with: innerStackConfig.layoutMargins)
|
|
}
|
|
}
|
|
|
|
func reset() {
|
|
titleLabel.text = nil
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
#if compiler(>=6.2)
|
|
@available(iOS 26.0, *)
|
|
private class ContentViewWithGlassEffect: VisualEffectContentView {
|
|
private let titleLabel = CVLabel()
|
|
private let glassView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
|
|
private let wrapper: UIView
|
|
|
|
private var layoutConstraints = [NSLayoutConstraint]()
|
|
|
|
var rootView: UIView { wrapper }
|
|
|
|
init() {
|
|
glassView.cornerConfiguration = .capsule()
|
|
|
|
/// WARNING: Must wrap into a UIView or `titleLabel` won't be properly sized.
|
|
self.wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(glassView)
|
|
}
|
|
|
|
func configure(blurBackgroundColor: UIColor,
|
|
titleLabelConfig: CVLabelConfig,
|
|
innerStackConfig: CVStackViewConfig) {
|
|
|
|
titleLabelConfig.applyForRendering(label: titleLabel)
|
|
|
|
if titleLabel.superview == nil {
|
|
titleLabel.setContentHuggingLow()
|
|
glassView.contentView.addSubview(titleLabel)
|
|
titleLabel.autoPinEdgesToSuperviewEdges(with: innerStackConfig.layoutMargins)
|
|
}
|
|
}
|
|
|
|
func reset() {
|
|
titleLabel.text = nil
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: -
|
|
|
|
private class ContentViewNoVisualEffect {
|
|
private let titleLabel = CVLabel()
|
|
private let innerStack = ManualStackView(name: "dateHeader.innerStackView")
|
|
|
|
var rootView: UIView { innerStack }
|
|
|
|
fileprivate var wallpaperBlurView: CVWallpaperBlurView?
|
|
private func ensureWallpaperBlurView() -> CVWallpaperBlurView {
|
|
if let wallpaperBlurView {
|
|
return wallpaperBlurView
|
|
}
|
|
let wallpaperBlurView = CVWallpaperBlurView()
|
|
#if compiler(>=6.2)
|
|
if #available(iOS 26.0, *) {
|
|
// Will override `cornerRadius` set in `configure...`.
|
|
wallpaperBlurView.cornerConfiguration = .capsule()
|
|
}
|
|
#endif
|
|
self.wallpaperBlurView = wallpaperBlurView
|
|
return wallpaperBlurView
|
|
}
|
|
|
|
func configure(componentView: CVComponentDateHeader.CVComponentViewDateHeader,
|
|
cellMeasurement: CVCellMeasurement,
|
|
componentDelegate: CVComponentDelegate,
|
|
hasWallpaper: Bool,
|
|
titleLabelConfig: CVLabelConfig,
|
|
innerStackConfig: CVStackViewConfig,
|
|
isReusing: Bool) {
|
|
|
|
if !isReusing {
|
|
reset(resetReusableState: true)
|
|
}
|
|
|
|
titleLabelConfig.applyForRendering(label: titleLabel)
|
|
|
|
if isReusing {
|
|
innerStack.configureForReuse(config: innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: CVComponentDateHeader.measurementKey_innerStack)
|
|
} else {
|
|
if hasWallpaper {
|
|
let wallpaperBlurView = ensureWallpaperBlurView()
|
|
CVComponentBase.configureWallpaperBlurView(wallpaperBlurView: wallpaperBlurView,
|
|
maskCornerRadius: 8,
|
|
componentDelegate: componentDelegate)
|
|
innerStack.addSubviewToFillSuperviewEdges(wallpaperBlurView)
|
|
}
|
|
innerStack.configure(config: innerStackConfig,
|
|
cellMeasurement: cellMeasurement,
|
|
measurementKey: CVComponentDateHeader.measurementKey_innerStack,
|
|
subviews: [ titleLabel ])
|
|
}
|
|
}
|
|
|
|
public func reset(resetReusableState: Bool) {
|
|
if resetReusableState {
|
|
innerStack.reset()
|
|
|
|
titleLabel.removeFromSuperview()
|
|
|
|
wallpaperBlurView?.removeFromSuperview()
|
|
wallpaperBlurView?.resetContentAndConfiguration()
|
|
}
|
|
|
|
titleLabel.text = nil
|
|
}
|
|
}
|