Path: blob/main/Signal/ConversationView/Components/CVComponentUnreadIndicator.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
public import SignalUI
public class CVComponentUnreadIndicator: CVComponentBase, CVRootComponent {
public var componentKey: CVComponentKey { .unreadIndicator }
public var cellReuseIdentifier: CVCellReuseIdentifier {
CVCellReuseIdentifier.unreadIndicator
}
public let isDedicatedCell = true
override init(itemModel: CVItemModel) {
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 {
CVComponentViewUnreadIndicator()
}
public func configureForRendering(
componentView: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate,
) {
guard let componentView = componentView as? CVComponentViewUnreadIndicator else {
owsFailDebug("Unexpected componentView.")
return
}
let themeHasChanged = conversationStyle.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 = conversationStyle.isDarkThemeEnabled
componentView.hasWallpaper = hasWallpaper
let outerStack = componentView.outerStack
let innerStack = componentView.innerStack
let titleLabel = componentView.titleLabel
let strokeViewLeading = componentView.strokeViewLeading
let strokeViewTrailing = componentView.strokeViewTrailing
titleLabelConfig.applyForRendering(label: titleLabel)
if isReusing {
innerStack.configureForReuse(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
)
outerStack.configureForReuse(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
)
} else {
outerStack.reset()
let visualEffectView: UIVisualEffectView
if #available(iOS 26, *) {
let glassEffectView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
glassEffectView.contentView.addSubview(titleLabel)
glassEffectView.cornerConfiguration = .capsule()
visualEffectView = glassEffectView
} else {
let blurEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
blurEffectView.contentView.addSubview(titleLabel)
blurEffectView.layer.masksToBounds = true
visualEffectView = blurEffectView
}
titleLabel.autoPinEdgesToSuperviewEdges(with: Self.titleLabelMargins)
let wrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(visualEffectView)
if #unavailable(iOS 26) {
wrapper.addLayoutBlock { view in
visualEffectView.layer.cornerRadius = view.bounds.size.smallerAxis / 2
}
}
innerStack.reset()
innerStack.configure(
config: innerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_innerStack,
subviews: [wrapper],
)
let strokeViewStyle: StrokeView.Style = hasWallpaper ? .double : .single
strokeViewLeading.style = strokeViewStyle
strokeViewTrailing.style = strokeViewStyle
outerStack.configure(
config: outerStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_outerStack,
subviews: [
strokeViewLeading,
innerStack,
strokeViewTrailing,
],
)
}
}
private var titleLabelConfig: CVLabelConfig {
CVLabelConfig.unstyledText(
OWSLocalizedString(
"MESSAGES_VIEW_UNREAD_INDICATOR",
comment: "Indicator that separates read from unread messages.",
),
font: UIFont.dynamicTypeFootnote.medium(),
textColor: Theme.primaryTextColor,
numberOfLines: 0,
lineBreakMode: .byTruncatingTail,
textAlignment: .center,
)
}
private static var titleLabelMargins = UIEdgeInsets(hMargin: 12, vMargin: 3)
private var outerStackConfig: CVStackViewConfig {
let cellLayoutMargins = UIEdgeInsets(
top: 8,
leading: conversationStyle.fullWidthGutterLeading,
bottom: 8,
trailing: conversationStyle.fullWidthGutterTrailing,
)
return CVStackViewConfig(
axis: .horizontal,
alignment: .center,
spacing: 6,
layoutMargins: cellLayoutMargins,
)
}
private var innerStackConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .center,
spacing: 0,
layoutMargins: .zero,
)
}
private static let measurementKey_outerStack = "CVComponentUnreadIndicator.measurementKey_outerStack"
private static let measurementKey_innerStack = "CVComponentUnreadIndicator.measurementKey_innerStack"
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
let availableWidth = max(0, maxWidth - (Self.titleLabelMargins.totalWidth + outerStackConfig.layoutMargins.totalWidth))
let labelSize = CVText.measureLabel(config: titleLabelConfig, maxWidth: availableWidth) + Self.titleLabelMargins.asSize
let strokeSize = CGSize(width: 0, height: 2)
let labelInfo = labelSize.asManualSubviewInfo
let innerStackMeasurement = ManualStackView.measure(
config: innerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_innerStack,
subviewInfos: [labelInfo],
)
let strokeInfo = strokeSize.asManualSubviewInfo(hasFixedHeight: true)
let innerStackInfo = innerStackMeasurement.measuredSize.asManualSubviewInfo(hasFixedWidth: true)
let hStackSubviewInfos = [
strokeInfo,
innerStackInfo,
strokeInfo,
]
let hStackMeasurement = ManualStackView.measure(
config: outerStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_outerStack,
subviewInfos: hStackSubviewInfos,
maxWidth: maxWidth,
)
return hStackMeasurement.measuredSize
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
public class CVComponentViewUnreadIndicator: NSObject, CVComponentView {
fileprivate let outerStack = ManualStackView(name: "unreadIndicator.outerStack")
fileprivate let innerStack = ManualStackView(name: "unreadIndicator.innerStack")
fileprivate let titleLabel = CVLabel()
fileprivate var hasWallpaper = false
fileprivate var isDarkThemeEnabled = false
fileprivate let strokeViewLeading = StrokeView()
fileprivate let strokeViewTrailing = StrokeView()
public var isDedicatedCellView = false
public var rootView: UIView {
outerStack
}
// MARK: -
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
reset(resetReusableState: false)
}
public func reset(resetReusableState: Bool) {
owsAssertDebug(isDedicatedCellView)
titleLabel.text = nil
if resetReusableState {
outerStack.reset()
innerStack.reset()
hasWallpaper = false
isDarkThemeEnabled = false
}
}
}
fileprivate class StrokeView: ManualLayoutView {
enum Style {
case single
case double
}
var style: Style = .single {
didSet {
updateStokeStyle()
}
}
private let topStrokeView = UIView()
private let middleStrokeView = UIView()
private let bottomStrokeView = UIView()
init() {
super.init(name: "StrokeView")
clipsToBounds = true
topStrokeView.backgroundColor = UIColor(white: 0, alpha: 0.32)
middleStrokeView.backgroundColor = UIColor.Signal.quaternaryLabel
bottomStrokeView.backgroundColor = UIColor(white: 1, alpha: 0.16)
addSubview(topStrokeView)
addSubview(middleStrokeView)
addSubview(bottomStrokeView)
addDefaultLayoutBlock()
updateStokeStyle()
}
private func addDefaultLayoutBlock() {
addLayoutBlock { [weak self] _ in
guard let self else { return }
let strokeViewSize = CGSize(width: self.bounds.width, height: 1)
self.topStrokeView.frame = CGRect(
origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY - strokeViewSize.height),
size: strokeViewSize,
)
self.middleStrokeView.frame = CGRect(
origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY - strokeViewSize.height / 2),
size: strokeViewSize,
)
self.bottomStrokeView.frame = CGRect(
origin: CGPoint(x: self.bounds.minX, y: self.bounds.midY),
size: strokeViewSize,
)
}
}
private func updateStokeStyle() {
topStrokeView.isHidden = style == .single
bottomStrokeView.isHidden = style == .single
middleStrokeView.isHidden = style == .double
}
}
}