mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Polish iOS header glass and scroll-edge blur (#8097)
Wrap channel and DM titles in native glass and remove the temporary-channel clock. Apply a soft status-area fade across iPhone navigation, and fade the large title with scroll progress and fade in the centered title, with Reduce Motion support. Validation: mobile formatting/analysis and the full mobile suite passed. Native iOS 26/27 simulator checks covered actual text opacity, header resizing, quick reversal, and restored heading position; signed iPhone build, installation, and launch succeeded. `just ci` was blocked by disk exhaustion during Rust compilation. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -22,21 +22,25 @@ final class IosNavigationBarFactory: NSObject, FlutterPlatformViewFactory {
|
||||
}
|
||||
}
|
||||
|
||||
final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate {
|
||||
var maximumWidth: CGFloat = 240 {
|
||||
didSet { if maximumWidth != oldValue { invalidateIntrinsicContentSize() } }
|
||||
}
|
||||
var onActivate: (() -> Void)?
|
||||
var onExpiryPressed: ((String) -> Void)?
|
||||
private let titleLabel = UILabel()
|
||||
private let subtitleLabel = UILabel()
|
||||
private var avatarView: UIImageView?
|
||||
private var presenceView: UIView?
|
||||
private var subtitlePresenceView: UIView?
|
||||
private var expiryView: UIButton?
|
||||
|
||||
init(title: String?, subtitle: String, color: UIColor) {
|
||||
super.init(frame: .zero)
|
||||
if #available(iOS 26.0, *) {
|
||||
super.init(effect: UIGlassEffect(style: .regular))
|
||||
} else {
|
||||
super.init(effect: UIBlurEffect(style: .systemMaterial))
|
||||
}
|
||||
clipsToBounds = true
|
||||
layer.cornerCurve = .continuous
|
||||
titleLabel.text = title
|
||||
titleLabel.font = .preferredFont(forTextStyle: .headline)
|
||||
titleLabel.textColor = color
|
||||
@@ -47,7 +51,7 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
label.textAlignment = .center
|
||||
label.lineBreakMode = .byTruncatingTail
|
||||
label.adjustsFontForContentSizeCategory = false
|
||||
addSubview(label)
|
||||
contentView.addSubview(label)
|
||||
}
|
||||
// Flutter creates platform views with a zero frame. Keep a nonzero
|
||||
// intrinsic width and let UINavigationBar compress it between its items.
|
||||
@@ -63,7 +67,7 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
let avatar = UIImageView(image: image)
|
||||
avatar.accessibilityIdentifier = "dm-navigation-avatar"
|
||||
avatar.contentMode = .scaleAspectFit
|
||||
addSubview(avatar)
|
||||
contentView.addSubview(avatar)
|
||||
avatarView = avatar
|
||||
for label in [titleLabel, subtitleLabel] { label.textAlignment = .natural }
|
||||
if let presence {
|
||||
@@ -73,7 +77,7 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
badge.layer.borderWidth = 1.5
|
||||
badge.layer.borderColor = UIColor.systemBackground.cgColor
|
||||
badge.accessibilityIdentifier = "dm-navigation-presence"
|
||||
addSubview(badge)
|
||||
contentView.addSubview(badge)
|
||||
presenceView = badge
|
||||
}
|
||||
}
|
||||
@@ -84,22 +88,14 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
dot.layer.cornerRadius = 3
|
||||
dot.isAccessibilityElement = false
|
||||
dot.accessibilityIdentifier = "dm-navigation-status-dot"
|
||||
addSubview(dot)
|
||||
contentView.addSubview(dot)
|
||||
subtitlePresenceView = dot
|
||||
invalidateIntrinsicContentSize()
|
||||
}
|
||||
|
||||
func setEphemeralStatus(_ label: String) {
|
||||
let clock = UIButton(type: .custom)
|
||||
clock.setImage(UIImage(systemName: "clock"), for: .normal)
|
||||
clock.tintColor = .secondaryLabel
|
||||
clock.addAction(UIAction { [weak self] _ in self?.onExpiryPressed?(label) }, for: .touchUpInside)
|
||||
clock.accessibilityIdentifier = "navigation-ephemeral-status"
|
||||
// The title is one accessibility element; include the full retention
|
||||
// explanation there so the clock is never announced without its meaning.
|
||||
clock.isAccessibilityElement = false
|
||||
addSubview(clock)
|
||||
expiryView = clock
|
||||
// The subtitle carries the visible temporary/expiry text. Keep the full
|
||||
// retention explanation on the title's single VoiceOver element too.
|
||||
accessibilityLabel = [accessibilityLabel, label].compactMap { $0 }.joined(separator: ", ")
|
||||
}
|
||||
|
||||
@@ -108,7 +104,7 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
// UIKit can wrap titleView in a UIControl. Only controls inside our title
|
||||
// (such as the retention disclosure) should consume the title tap.
|
||||
// should consume the title tap.
|
||||
func acceptsTitleTouch(in touchedView: UIView?) -> Bool {
|
||||
var touched = touchedView
|
||||
while let view = touched, view !== self {
|
||||
@@ -129,12 +125,13 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
|
||||
override var intrinsicContentSize: CGSize {
|
||||
CGSize(width: min(maximumWidth, max(titleLabel.intrinsicContentSize.width, subtitleLabel.intrinsicContentSize.width + (subtitlePresenceView == nil ? 0 : 12)) + 16 + (avatarView == nil ? 0 : 40) + (expiryView == nil ? 0 : 44)),
|
||||
CGSize(width: min(maximumWidth, max(titleLabel.intrinsicContentSize.width, subtitleLabel.intrinsicContentSize.width + (subtitlePresenceView == nil ? 0 : 12)) + 24 + (avatarView == nil ? 0 : 40)),
|
||||
height: max(44, titleLabel.intrinsicContentSize.height + subtitleLabel.intrinsicContentSize.height))
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
layer.cornerRadius = bounds.height / 2
|
||||
// Compact navigation remains a 44pt toolbar. Scale within that budget;
|
||||
// the complete title/subtitle remains available as one VoiceOver label.
|
||||
titleLabel.font = UIFontMetrics(forTextStyle: .headline).scaledFont(
|
||||
@@ -148,11 +145,8 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
let top = (bounds.height - titleHeight - subtitleHeight) / 2
|
||||
let hasAvatar = avatarView != nil
|
||||
let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft
|
||||
let statusWidth: CGFloat = expiryView == nil ? 0 : 44
|
||||
let textX: CGFloat = (hasAvatar && !rtl ? 48 : 8) + (rtl ? statusWidth : 0)
|
||||
let textWidth = max(0, bounds.width - 16 - (hasAvatar ? 40 : 0) - statusWidth)
|
||||
expiryView?.frame = CGRect(x: rtl ? 8 : bounds.width - 52,
|
||||
y: 0, width: 44, height: bounds.height)
|
||||
let textX: CGFloat = hasAvatar && !rtl ? 52 : 12
|
||||
let textWidth = max(0, bounds.width - 24 - (hasAvatar ? 40 : 0))
|
||||
titleLabel.frame = CGRect(x: textX, y: top, width: textWidth, height: titleHeight)
|
||||
subtitleLabel.frame = CGRect(x: textX, y: top + titleHeight, width: textWidth, height: subtitleHeight)
|
||||
if let dot = subtitlePresenceView {
|
||||
@@ -163,7 +157,7 @@ final class NavigationTitleView: UIView, UIGestureRecognizerDelegate {
|
||||
subtitleLabel.frame = CGRect(x: rtl ? groupX : groupX + 12,
|
||||
y: top + titleHeight, width: labelWidth, height: subtitleHeight)
|
||||
}
|
||||
let avatarX: CGFloat = rtl ? bounds.width - 40 : 8
|
||||
let avatarX: CGFloat = rtl ? bounds.width - 44 : 12
|
||||
avatarView?.frame = CGRect(x: avatarX, y: (bounds.height - 32) / 2, width: 32, height: 32)
|
||||
presenceView?.frame = CGRect(x: avatarX + (rtl ? 0 : 24), y: (bounds.height - 32) / 2 + 24, width: 8, height: 8)
|
||||
}
|
||||
@@ -185,10 +179,9 @@ private final class NavigationContentController: UIViewController {
|
||||
scrollView.isUserInteractionEnabled = false
|
||||
scrollView.backgroundColor = .clear
|
||||
if #available(iOS 26.0, *) {
|
||||
// This scroll view only drives title layout. Its automatic edge effect
|
||||
// otherwise adds a second, hard-edged backdrop when the title collapses.
|
||||
// The material behind the navigation controller owns the visible blur.
|
||||
scrollView.topEdgeEffect.isHidden = true
|
||||
// Configure the native effect before UIKit lays out the bar; the
|
||||
// platform-view configuration selects it or the material fallback.
|
||||
scrollView.topEdgeEffect.style = .soft
|
||||
scrollView.bottomEdgeEffect.isHidden = true
|
||||
}
|
||||
}
|
||||
@@ -198,11 +191,22 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
private let container: NavigationClipView
|
||||
private let material = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
|
||||
private let materialFade = CAGradientLayer()
|
||||
// A transparent, noninteractive viewport provides the system status fade
|
||||
// independently of the layout-only scroll view that collapses large titles.
|
||||
private let edgeScrollView = UIScrollView()
|
||||
private let content = NavigationContentController()
|
||||
private let navigation: UINavigationController
|
||||
private let channel: FlutterMethodChannel
|
||||
private var offset: CGFloat = 0
|
||||
private var alwaysFrosted = false
|
||||
private var usesSystemScrollEdge = false
|
||||
private var expandedBarHeight: CGFloat = 0
|
||||
private var titleIsCollapsed: Bool?
|
||||
private let compactTitle = UILabel()
|
||||
private let compactTitleContainer = UIStackView()
|
||||
private let compactTitleMask = CALayer()
|
||||
private var titleColor = UIColor.label
|
||||
private var largeTitleOpacity: CGFloat = -1
|
||||
private var measuredWidth: CGFloat = 0
|
||||
private var measuredSafeTop: CGFloat = -1
|
||||
private var measuredCategory: UIContentSizeCategory?
|
||||
@@ -227,6 +231,17 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
materialFade.locations = [0, 0.55, 1]
|
||||
material.layer.mask = materialFade
|
||||
container.addSubview(material)
|
||||
if #available(iOS 27.0, *) {
|
||||
edgeScrollView.accessibilityIdentifier = "navigation-status-edge"
|
||||
edgeScrollView.isUserInteractionEnabled = false
|
||||
edgeScrollView.accessibilityElementsHidden = true
|
||||
edgeScrollView.backgroundColor = .clear
|
||||
edgeScrollView.contentInsetAdjustmentBehavior = .never
|
||||
edgeScrollView.contentSize = CGSize(width: 1, height: 10000)
|
||||
edgeScrollView.topEdgeEffect.style = .soft
|
||||
edgeScrollView.bottomEdgeEffect.isHidden = true
|
||||
container.addSubview(edgeScrollView)
|
||||
}
|
||||
navigation.view.backgroundColor = .clear
|
||||
navigation.navigationBar.isTranslucent = true
|
||||
parent?.addChild(navigation)
|
||||
@@ -256,16 +271,36 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
// Only the bar is exposed by the platform-view clip. A full viewport is
|
||||
// needed for UIKit's scroll-edge and large-title calculations.
|
||||
guard !measuring else { return }
|
||||
material.frame = container.bounds
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
materialFade.frame = material.bounds
|
||||
CATransaction.commit()
|
||||
let viewportHeight = container.window?.bounds.height ?? container.bounds.height
|
||||
navigation.view.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: viewportHeight)
|
||||
edgeScrollView.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: viewportHeight)
|
||||
navigation.view.frame = edgeScrollView.frame
|
||||
navigation.view.layoutIfNeeded()
|
||||
measureIfNeeded()
|
||||
applyScroll()
|
||||
// Glass-backed conversations need only a short status-area fade.
|
||||
// Recompute the material bounds after rotation and inset changes.
|
||||
let statusHeight = navigation.view.safeAreaInsets.top
|
||||
// Before iOS 26 the actions have no glass, and subtitle-less thread
|
||||
// titles remain plain UIKit labels even on iOS 26. They need material
|
||||
// beneath the entire control area, not just the status indicators.
|
||||
let glassBackedControls: Bool
|
||||
if #available(iOS 26.0, *) {
|
||||
glassBackedControls = content.navigationItem.titleView is NavigationTitleView
|
||||
} else {
|
||||
glassBackedControls = false
|
||||
}
|
||||
let statusOnly = alwaysFrosted && !navigation.navigationBar.prefersLargeTitles && glassBackedControls
|
||||
let height = statusOnly
|
||||
? min(container.bounds.height, statusHeight > 0 ? statusHeight + 6 : 0)
|
||||
: container.bounds.height
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
material.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: height)
|
||||
materialFade.frame = material.bounds
|
||||
materialFade.locations = statusOnly
|
||||
? [0, NSNumber(value: Double(max(0, height - 12) / max(1, height))), 1]
|
||||
: (navigation.navigationBar.prefersLargeTitles ? [0, 0.55, 1] : [0, 0.85, 1])
|
||||
CATransaction.commit()
|
||||
reportAvatarBounds()
|
||||
}
|
||||
|
||||
@@ -287,32 +322,50 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
private func configure(_ args: [String: Any]) {
|
||||
navigation.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
|
||||
material.overrideUserInterfaceStyle = navigation.overrideUserInterfaceStyle
|
||||
// Refresh the system material tint whenever the Flutter theme changes.
|
||||
material.contentView.backgroundColor = args["background"] is NSNumber
|
||||
? Self.color(args["background"]).withAlphaComponent(0.25) : .clear
|
||||
edgeScrollView.overrideUserInterfaceStyle = navigation.overrideUserInterfaceStyle
|
||||
// Let the ultra-thin system material provide its own adaptive tint.
|
||||
material.contentView.backgroundColor = .clear
|
||||
let bar = navigation.navigationBar
|
||||
let color = Self.color(args["foreground"])
|
||||
bar.tintColor = color
|
||||
titleColor = color
|
||||
largeTitleOpacity = -1
|
||||
let largeTitle = args["largeTitle"] as? Bool == true
|
||||
alwaysFrosted = args["alwaysFrosted"] as? Bool == true
|
||||
if #available(iOS 26.0, *) {
|
||||
if #available(iOS 27.0, *) { usesSystemScrollEdge = true }
|
||||
content.scrollView.topEdgeEffect.style = .soft
|
||||
content.scrollView.topEdgeEffect.isHidden = !usesSystemScrollEdge || !alwaysFrosted || largeTitle
|
||||
edgeScrollView.isHidden = alwaysFrosted && !largeTitle
|
||||
}
|
||||
// iOS 26 does not composite this native edge over sibling Flutter content
|
||||
// in our embedding, so retain the ultra-thin material fallback there.
|
||||
// Conversations use the navigation controller's native edge. Other pages
|
||||
// use a stationary native edge so UIKit's large-title inset adjustments
|
||||
// cannot hide the status-area fade when the title expands or collapses.
|
||||
material.isHidden = usesSystemScrollEdge
|
||||
// Let the native fade extend past the bar without enlarging its hit area.
|
||||
// The mirrored content is transparent and the bottom edge is hidden.
|
||||
container.clipsToBounds = !usesSystemScrollEdge
|
||||
let appearance = UINavigationBarAppearance()
|
||||
// The mirrored UIScrollView contains no rendered Flutter content, so
|
||||
// UIKit's automatic scroll-edge treatment cannot detect its backdrop.
|
||||
// A native material below the bar samples the real composited page instead.
|
||||
appearance.configureWithTransparentBackground()
|
||||
if usesSystemScrollEdge {
|
||||
appearance.configureWithDefaultBackground()
|
||||
} else {
|
||||
appearance.configureWithTransparentBackground()
|
||||
}
|
||||
appearance.titleTextAttributes = [.foregroundColor: color]
|
||||
appearance.largeTitleTextAttributes = [.foregroundColor: color]
|
||||
bar.standardAppearance = appearance
|
||||
bar.scrollEdgeAppearance = appearance
|
||||
bar.compactAppearance = appearance
|
||||
let largeTitle = args["largeTitle"] as? Bool == true
|
||||
if bar.prefersLargeTitles != largeTitle { measuredWidth = 0 }
|
||||
if bar.prefersLargeTitles != largeTitle {
|
||||
measuredWidth = 0
|
||||
titleIsCollapsed = nil
|
||||
}
|
||||
bar.prefersLargeTitles = largeTitle
|
||||
// A title alone does not need a backdrop. Reveal material only as the
|
||||
// page scrolls beneath the navigation controls, for every title size.
|
||||
material.alpha = min(1, offset / 12)
|
||||
// Use the same ultra-thin material and soft lower edge on every page.
|
||||
// Compact titles have a member-count line, so begin their fade below it
|
||||
// rather than washing out the subtitle or ending in a hard rectangle.
|
||||
materialFade.locations = largeTitle ? [0, 0.55, 1] : [0, 0.85, 1]
|
||||
// Conversations request a stable backdrop from the first frame, including
|
||||
// loading/empty timelines. Other pages retain their scroll-edge treatment.
|
||||
material.alpha = alwaysFrosted ? 1 : min(1, offset / 12)
|
||||
let item = content.navigationItem
|
||||
item.title = args["title"] as? String
|
||||
if let subtitle = args["subtitle"] as? String {
|
||||
@@ -323,16 +376,10 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
? "Open settings for \(item.title ?? ""), \(subtitle)"
|
||||
: "\(item.title ?? ""), \(subtitle)"
|
||||
button.accessibilityTraits = enabled ? .button : .header
|
||||
button.isUserInteractionEnabled = enabled || args["ephemeralLabel"] is String
|
||||
button.isUserInteractionEnabled = enabled
|
||||
if enabled {
|
||||
button.onActivate = { [weak self] in self?.channel.invokeMethod("action", arguments: "title") }
|
||||
}
|
||||
button.onExpiryPressed = { [weak self] label in
|
||||
guard let self, self.content.presentedViewController == nil else { return }
|
||||
let disclosure = UIAlertController(title: "Temporary conversation", message: label, preferredStyle: .alert)
|
||||
disclosure.addAction(UIAlertAction(title: "OK", style: .default))
|
||||
self.content.present(disclosure, animated: true)
|
||||
}
|
||||
if let avatar = args["titleAvatar"] as? [String: Any] {
|
||||
button.setAvatar(makeItem(avatar).image,
|
||||
presence: args["titlePresenceColor"] is NSNumber ? Self.color(args["titlePresenceColor"]) : nil)
|
||||
@@ -345,6 +392,18 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
}
|
||||
button.frame.size = button.intrinsicContentSize
|
||||
item.titleView = button
|
||||
} else if largeTitle {
|
||||
compactTitle.text = item.title
|
||||
compactTitle.font = .preferredFont(forTextStyle: .headline)
|
||||
compactTitle.adjustsFontForContentSizeCategory = true
|
||||
compactTitle.textColor = color
|
||||
compactTitle.accessibilityTraits = .header
|
||||
compactTitle.sizeToFit()
|
||||
compactTitleMask.backgroundColor = UIColor.black.cgColor
|
||||
compactTitle.layer.mask = compactTitleMask
|
||||
if compactTitle.superview == nil { compactTitleContainer.addArrangedSubview(compactTitle) }
|
||||
compactTitleContainer.frame.size = compactTitle.intrinsicContentSize
|
||||
item.titleView = compactTitleContainer
|
||||
} else {
|
||||
item.titleView = nil
|
||||
}
|
||||
@@ -371,6 +430,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
let safeTop = navigation.view.safeAreaInsets.top
|
||||
guard measuredWidth != container.bounds.width || measuredSafeTop != safeTop || measuredCategory != category else { return }
|
||||
measuring = true
|
||||
titleIsCollapsed = nil
|
||||
defer { measuring = false }
|
||||
let bar = navigation.navigationBar
|
||||
let large = bar.prefersLargeTitles
|
||||
@@ -401,7 +461,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
|
||||
private func setScrollOffset(_ value: CGFloat) {
|
||||
offset = max(0, value)
|
||||
material.alpha = min(1, offset / 12)
|
||||
material.alpha = alwaysFrosted ? 1 : min(1, offset / 12)
|
||||
applyScroll()
|
||||
reportAvatarBounds()
|
||||
}
|
||||
@@ -415,10 +475,60 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
// safe area, throughout the scroll cycle.
|
||||
expandedBarHeight = max(expandedBarHeight, navigation.navigationBar.frame.height)
|
||||
let topInset = navigation.view.safeAreaInsets.top + expandedBarHeight
|
||||
let desired = CGPoint(x: 0, y: -topInset + offset)
|
||||
if abs(scroll.contentOffset.y - desired.y) > 0.1 {
|
||||
// Compact conversations always have their content behind the overlay.
|
||||
// Zero represents the viewport's top, not an empty native inset; this
|
||||
// makes the system edge ready before Flutter's first scroll notification.
|
||||
let contentBehindBar = usesSystemScrollEdge && alwaysFrosted && !navigation.navigationBar.prefersLargeTitles
|
||||
let desired = CGPoint(x: 0, y: contentBehindBar ? offset : -topInset + offset)
|
||||
let compactHeight = (metrics?["compactHeight"] as? CGFloat) ?? navigation.navigationBar.frame.height
|
||||
let collapseRange = max(1, expandedBarHeight - compactHeight)
|
||||
let collapsed = offset >= collapseRange
|
||||
let wasCollapsed = titleIsCollapsed
|
||||
titleIsCollapsed = collapsed
|
||||
UIView.performWithoutAnimation {
|
||||
if navigation.navigationBar.prefersLargeTitles {
|
||||
// Fade the actual text throughout the last part of the gesture. A
|
||||
// snapshot transition of the platform view gets clipped by Flutter's
|
||||
// shrinking header and can still look like a one-frame title switch.
|
||||
let progress = min(1, max(0, (offset - collapseRange * 0.35) / (collapseRange * 0.65)))
|
||||
let opacity = 1 - progress
|
||||
if abs(largeTitleOpacity - opacity) > 0.001 {
|
||||
largeTitleOpacity = opacity
|
||||
for appearance in [navigation.navigationBar.standardAppearance,
|
||||
navigation.navigationBar.scrollEdgeAppearance,
|
||||
navigation.navigationBar.compactAppearance].compactMap({ $0 }) {
|
||||
appearance.largeTitleTextAttributes[.foregroundColor] = titleColor.withAlphaComponent(opacity)
|
||||
}
|
||||
navigation.navigationBar.largeTitleTextAttributes = [.foregroundColor: titleColor.withAlphaComponent(opacity)]
|
||||
}
|
||||
}
|
||||
scroll.setContentOffset(desired, animated: false)
|
||||
navigation.view.layoutIfNeeded()
|
||||
// Expanding changes UIKit's inset and can compensate the offset.
|
||||
if abs(scroll.contentOffset.y - desired.y) > 0.1 {
|
||||
scroll.setContentOffset(desired, animated: false)
|
||||
navigation.view.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
if navigation.navigationBar.prefersLargeTitles {
|
||||
// UIKit owns title-view alpha on some iOS versions. A mask keeps the
|
||||
// text fade independent from its layout-driven visibility changes.
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
compactTitleMask.frame = compactTitle.bounds
|
||||
compactTitleMask.opacity = collapsed ? 1 : 0
|
||||
CATransaction.commit()
|
||||
if wasCollapsed != collapsed {
|
||||
compactTitleMask.removeAllAnimations()
|
||||
if collapsed && wasCollapsed != nil && !UIAccessibility.isReduceMotionEnabled {
|
||||
let fade = CABasicAnimation(keyPath: "opacity")
|
||||
fade.fromValue = 0
|
||||
fade.toValue = 1
|
||||
fade.duration = 0.18
|
||||
fade.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
||||
compactTitleMask.add(fade, forKey: "titleFade")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ class RunnerTests: XCTestCase {
|
||||
let bar = factory.create(withFrame: .zero, viewIdentifier: 999991,
|
||||
arguments: ["title": "Alice, Bob", "subtitle": "3 members", "titleEnabled": true])
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
XCTAssertGreaterThan(title.frame.width, 0)
|
||||
XCTAssertGreaterThan(title.intrinsicContentSize.width, 0)
|
||||
XCTAssertLessThanOrEqual(title.intrinsicContentSize.width, 240)
|
||||
@@ -129,7 +129,7 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertEqual(title.intrinsicContentSize.width, 180)
|
||||
title.frame = CGRect(origin: .zero, size: title.intrinsicContentSize)
|
||||
title.layoutIfNeeded()
|
||||
for label in title.subviews.compactMap({ $0 as? UILabel }) {
|
||||
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) {
|
||||
XCTAssertEqual(label.lineBreakMode, .byTruncatingTail)
|
||||
XCTAssertTrue(title.bounds.contains(label.frame))
|
||||
}
|
||||
@@ -141,8 +141,8 @@ class RunnerTests: XCTestCase {
|
||||
title.setSubtitlePresence(.gray)
|
||||
title.frame = CGRect(x: 0, y: 0, width: 180, height: 44)
|
||||
title.layoutIfNeeded()
|
||||
let dot = try XCTUnwrap(title.subviews.first { $0.accessibilityIdentifier == "dm-navigation-status-dot" })
|
||||
let label = try XCTUnwrap(title.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Offline" })
|
||||
let dot = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-status-dot" })
|
||||
let label = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == "Offline" })
|
||||
XCTAssertEqual(label.frame.minX - dot.frame.maxX, 6, accuracy: 0.1)
|
||||
XCTAssertEqual(label.frame.midY, dot.frame.midY, accuracy: 0.1)
|
||||
XCTAssertEqual((dot.frame.minX + label.frame.maxX) / 2, title.bounds.midX, accuracy: 0.1)
|
||||
@@ -184,7 +184,7 @@ class RunnerTests: XCTestCase {
|
||||
parent.view.layoutIfNeeded()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
// UIKit may cap inherited toolbar traits; also exercise an explicit AX
|
||||
// override while preserving the real navigation bar's allocated bounds.
|
||||
title.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
|
||||
@@ -192,7 +192,7 @@ class RunnerTests: XCTestCase {
|
||||
title.layoutIfNeeded()
|
||||
XCTAssertEqual(title.traitCollection.preferredContentSizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||
XCTAssertTrue(title.accessibilityLabel?.contains(subtitle) == true)
|
||||
for label in title.subviews.compactMap({ $0 as? UILabel }) where !label.isHidden {
|
||||
for label in title.contentView.subviews.compactMap({ $0 as? UILabel }) where !label.isHidden {
|
||||
XCTAssertTrue(title.bounds.contains(label.frame))
|
||||
XCTAssertGreaterThanOrEqual(label.bounds.height, label.intrinsicContentSize.height)
|
||||
let frame = label.convert(label.bounds, to: navigation.navigationBar)
|
||||
@@ -226,53 +226,44 @@ class RunnerTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeConversationExpiryDisclosure() async throws {
|
||||
func testNativeConversationRetentionTextAndAccessibility() throws {
|
||||
for dm in [false, true] {
|
||||
let messenger = NavigationTestMessenger()
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = parent
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
||||
let expiry = "Ephemeral channel. Cleans up after 1 hour of inactivity."
|
||||
var args: [String: Any] = ["title": dm ? "Alice" : "general",
|
||||
"subtitle": dm ? "Online" : "2 members",
|
||||
"titleEnabled": !dm, "ephemeralLabel": expiry]
|
||||
if dm { args["titleAvatar"] = ["avatarInitial": "A"] }
|
||||
let bar = factory.create(withFrame: CGRect(x: 0, y: 0, width: 390, height: 120),
|
||||
viewIdentifier: 999995, arguments: args)
|
||||
parent.view.addSubview(bar.view())
|
||||
var args: [String: Any] = ["title": dm ? "Alice" : "general", "titleEnabled": true]
|
||||
let bar = factory.create(withFrame: .zero, viewIdentifier: 999995, arguments: args)
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
title.frame.size = title.intrinsicContentSize
|
||||
for direction in [UISemanticContentAttribute.forceLeftToRight, .forceRightToLeft] {
|
||||
title.semanticContentAttribute = direction
|
||||
title.setNeedsLayout()
|
||||
title.layoutIfNeeded()
|
||||
let clock = try XCTUnwrap(title.subviews.first { $0.accessibilityIdentifier == "navigation-ephemeral-status" } as? UIButton)
|
||||
XCTAssertNotNil(clock.image(for: .normal))
|
||||
XCTAssertFalse(clock.isHidden)
|
||||
XCTAssertTrue(title.bounds.contains(clock.frame))
|
||||
for label in title.subviews.compactMap({ $0 as? UILabel }) {
|
||||
XCTAssertFalse(label.frame.intersects(clock.frame))
|
||||
for (subtitle, explanation) in [
|
||||
("Temporary · 1h TTL · Online", "Ephemeral channel. Cleans up after 1 hour of inactivity."),
|
||||
("Temporary · Cleanup due · Online", "Ephemeral channel. Cleanup is due now."),
|
||||
] {
|
||||
args["subtitle"] = subtitle
|
||||
args["ephemeralLabel"] = explanation
|
||||
messenger.configure(args)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
title.frame.size = title.intrinsicContentSize
|
||||
for direction in [UISemanticContentAttribute.forceLeftToRight, .forceRightToLeft] {
|
||||
title.semanticContentAttribute = direction
|
||||
title.layoutIfNeeded()
|
||||
let status = try XCTUnwrap(title.contentView.subviews.compactMap { $0 as? UILabel }.first { $0.text == subtitle })
|
||||
XCTAssertFalse(status.isHidden)
|
||||
XCTAssertGreaterThan(status.bounds.width, 0)
|
||||
XCTAssertTrue(title.bounds.contains(status.frame))
|
||||
XCTAssertTrue(title.isAccessibilityElement)
|
||||
XCTAssertTrue(title.accessibilityLabel?.contains(explanation) == true)
|
||||
XCTAssertFalse(title.contentView.subviews.contains { $0 is UIButton }, "Retention stays text-only")
|
||||
}
|
||||
XCTAssertTrue(title.accessibilityLabel?.contains(expiry) == true)
|
||||
XCTAssertTrue(title.accessibilityActivate())
|
||||
XCTAssertEqual(messenger.actions.last, "title")
|
||||
}
|
||||
let clock = try XCTUnwrap(title.subviews.first { $0.accessibilityIdentifier == "navigation-ephemeral-status" } as? UIButton)
|
||||
XCTAssertTrue(title.isUserInteractionEnabled)
|
||||
clock.sendActions(for: .touchUpInside)
|
||||
try await Task.sleep(nanoseconds: 400_000_000)
|
||||
let disclosure = try XCTUnwrap(navigation.topViewController?.presentedViewController as? UIAlertController)
|
||||
XCTAssertEqual(disclosure.message, expiry)
|
||||
XCTAssertTrue(messenger.actions.isEmpty, "The clock must not open channel settings")
|
||||
disclosure.dismiss(animated: false)
|
||||
try await Task.sleep(nanoseconds: 100_000_000)
|
||||
args["subtitle"] = "Online"
|
||||
args.removeValue(forKey: "ephemeralLabel")
|
||||
messenger.configure(args)
|
||||
let permanent = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
XCTAssertFalse(permanent.subviews.contains { $0.accessibilityIdentifier == "navigation-ephemeral-status" })
|
||||
let permanent = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
XCTAssertFalse(permanent.accessibilityLabel?.contains("Ephemeral") == true)
|
||||
XCTAssertFalse(permanent.contentView.subviews.compactMap { $0 as? UILabel }.contains { $0.text?.contains("Temporary") == true })
|
||||
_ = bar // Keep the platform view alive throughout reconfiguration.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,18 +281,18 @@ class RunnerTests: XCTestCase {
|
||||
)
|
||||
parent.view.addSubview(bar.view())
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
title.frame.size = title.intrinsicContentSize
|
||||
title.layoutIfNeeded()
|
||||
XCTAssertEqual(title.accessibilityLabel, "Alice, Online")
|
||||
XCTAssertEqual(title.accessibilityTraits, .header)
|
||||
XCTAssertFalse(title.accessibilityActivate())
|
||||
let avatar = try XCTUnwrap(title.subviews.first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
|
||||
let avatar = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
|
||||
XCTAssertNotNil(avatar.image)
|
||||
XCTAssertEqual(avatar.frame.size, CGSize(width: 32, height: 32))
|
||||
let presence = try XCTUnwrap(title.subviews.first { $0.accessibilityIdentifier == "dm-navigation-presence" })
|
||||
let presence = try XCTUnwrap(title.contentView.subviews.first { $0.accessibilityIdentifier == "dm-navigation-presence" })
|
||||
XCTAssertEqual(presence.backgroundColor, UIColor.green)
|
||||
let labels = title.subviews.compactMap { $0 as? UILabel }
|
||||
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
|
||||
XCTAssertEqual(labels.map(\.text), ["Alice", "Online"])
|
||||
for label in labels {
|
||||
XCTAssertTrue(title.bounds.contains(label.frame))
|
||||
@@ -328,11 +319,11 @@ class RunnerTests: XCTestCase {
|
||||
parent.view.layoutIfNeeded()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
title.layoutIfNeeded()
|
||||
// A plain title view must not receive the control's capsule presentation.
|
||||
XCTAssertFalse(title is UIControl)
|
||||
let labels = title.subviews.compactMap { $0 as? UILabel }
|
||||
// The title owns one native glass capsule and remains a single action.
|
||||
XCTAssertEqual(title.accessibilityTraits, .button)
|
||||
let labels = title.contentView.subviews.compactMap { $0 as? UILabel }
|
||||
XCTAssertEqual(labels.map(\.text), ["general", "36 members"])
|
||||
for label in labels {
|
||||
XCTAssertTrue(title.bounds.contains(label.frame))
|
||||
@@ -344,7 +335,115 @@ class RunnerTests: XCTestCase {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCompactNavigationMaterialFollowsScrollDepth() async throws {
|
||||
func testConversationGlassAndBackdropAreReadyBeforeScrolling() throws {
|
||||
for subtitle in ["36 members", "Online", ""] {
|
||||
let messenger = NavigationTestMessenger()
|
||||
let parent = UIViewController()
|
||||
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
||||
var args: [String: Any] = ["title": "Conversation", "subtitle": subtitle,
|
||||
"titleEnabled": true, "alwaysFrosted": true]
|
||||
let bar = factory.create(withFrame: .zero, viewIdentifier: 999994, arguments: args)
|
||||
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
||||
XCTAssertEqual(material.alpha, 1, "The initial configuration must not wait for a scroll")
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let title = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
if #available(iOS 26.0, *) {
|
||||
XCTAssertTrue(title.effect is UIGlassEffect)
|
||||
} else {
|
||||
XCTAssertTrue(title.effect is UIBlurEffect)
|
||||
}
|
||||
title.frame.size = title.intrinsicContentSize
|
||||
title.layoutIfNeeded()
|
||||
XCTAssertEqual(title.layer.cornerRadius, title.bounds.height / 2)
|
||||
XCTAssertEqual(title.contentView.subviews.compactMap { $0 as? UILabel }.count, 2)
|
||||
XCTAssertTrue(title.accessibilityActivate())
|
||||
XCTAssertEqual(messenger.actions.last, "title")
|
||||
for offset in [0.0, 6.0, 52.0, 0.0, -20.0] {
|
||||
messenger.scroll(to: offset)
|
||||
XCTAssertEqual(material.alpha, 1)
|
||||
args["dark"] = true
|
||||
args["subtitle"] = "Updated status"
|
||||
messenger.configure(args)
|
||||
XCTAssertEqual(material.alpha, 1, "Live status/theme changes must preserve the backdrop")
|
||||
}
|
||||
messenger.configure(["title": "Home", "largeTitle": true])
|
||||
XCTAssertEqual(material.alpha, 0, "Large-title pages keep their existing scroll treatment")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testConversationUsesNativeSoftEdgeWithoutExpandingHitArea() throws {
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = parent
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
let messenger = NavigationTestMessenger()
|
||||
let factory = IosNavigationBarFactory(messenger: messenger, parent: parent)
|
||||
let bar = factory.create(
|
||||
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
||||
viewIdentifier: 999993,
|
||||
arguments: ["title": "general", "subtitle": "36 members", "alwaysFrosted": true])
|
||||
parent.view.addSubview(bar.view())
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
||||
for offset in [0.0, 52.0, 0.0] {
|
||||
messenger.scroll(to: offset)
|
||||
bar.view().setNeedsLayout()
|
||||
bar.view().layoutIfNeeded()
|
||||
if #available(iOS 27.0, *) {
|
||||
let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
||||
XCTAssertEqual(scroll.topEdgeEffect.style, .soft)
|
||||
XCTAssertFalse(scroll.topEdgeEffect.isHidden)
|
||||
XCTAssertTrue(scroll.bottomEdgeEffect.isHidden)
|
||||
XCTAssertTrue(material.isHidden, "Do not stack a uniform blur over the native fade")
|
||||
XCTAssertFalse(bar.view().clipsToBounds)
|
||||
XCTAssertEqual(scroll.contentOffset.y, offset, accuracy: 0.5)
|
||||
XCTAssertFalse(bar.view().point(inside: CGPoint(x: 20, y: 121), with: nil))
|
||||
} else if #available(iOS 26.0, *) {
|
||||
let safeTop = navigation.view.safeAreaInsets.top
|
||||
XCTAssertEqual(material.frame.height, safeTop > 0 ? safeTop + 6 : 0, accuracy: 0.5)
|
||||
XCTAssertFalse(material.isHidden)
|
||||
} else {
|
||||
XCTAssertEqual(material.frame.height, bar.view().bounds.height, accuracy: 0.5)
|
||||
XCTAssertFalse(material.isHidden)
|
||||
}
|
||||
XCTAssertEqual(material.alpha, 1)
|
||||
XCTAssertNotNil(navigation.topViewController?.navigationItem.titleView as? NavigationTitleView)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSubtitlelessThreadKeepsFullFallbackBehindControls() throws {
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = parent
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
let messenger = NavigationTestMessenger()
|
||||
let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create(
|
||||
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 120),
|
||||
viewIdentifier: 999991, arguments: ["title": "Thread", "back": true, "alwaysFrosted": true])
|
||||
parent.view.addSubview(bar.view())
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
||||
for offset in [0.0, 52.0, 0.0] {
|
||||
messenger.scroll(to: offset)
|
||||
bar.view().setNeedsLayout()
|
||||
bar.view().layoutIfNeeded()
|
||||
XCTAssertNil(navigation.topViewController?.navigationItem.titleView)
|
||||
XCTAssertEqual(material.frame.height, bar.view().bounds.height, accuracy: 0.5)
|
||||
XCTAssertEqual(material.alpha, 1)
|
||||
if #available(iOS 27.0, *) {
|
||||
XCTAssertTrue(material.isHidden)
|
||||
} else {
|
||||
XCTAssertFalse(material.isHidden, "Plain thread titles and legacy buttons need a control-area backdrop")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testCompactNavigationUsesSoftEdgeWithLegacyMaterialFallback() async throws {
|
||||
let messenger = NavigationTestMessenger()
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
@@ -379,16 +478,96 @@ class RunnerTests: XCTestCase {
|
||||
XCTAssertEqual(material.alpha, min(1, max(0, offset) / 12), accuracy: 0.001,
|
||||
"Updating a compact title must not restore blur at rest")
|
||||
XCTAssertEqual(material.frame, bar.view().bounds)
|
||||
if #available(iOS 26.0, *) {
|
||||
if #available(iOS 27.0, *) {
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let mirroredScroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
||||
XCTAssertTrue(mirroredScroll.topEdgeEffect.isHidden,
|
||||
"The layout-only scroll view must not add a second header backdrop")
|
||||
XCTAssertTrue(mirroredScroll.topEdgeEffect.isHidden)
|
||||
let edge = try XCTUnwrap(bar.view().subviews.first {
|
||||
$0.accessibilityIdentifier == "navigation-status-edge"
|
||||
} as? UIScrollView)
|
||||
XCTAssertFalse(edge.isHidden)
|
||||
XCTAssertFalse(edge.topEdgeEffect.isHidden)
|
||||
XCTAssertEqual(edge.topEdgeEffect.style, .soft)
|
||||
XCTAssertEqual(edge.contentOffset, .zero)
|
||||
XCTAssertFalse(edge.isUserInteractionEnabled)
|
||||
XCTAssertTrue(material.isHidden, "Only the native fade should be visible")
|
||||
XCTAssertTrue(mirroredScroll.bottomEdgeEffect.isHidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLargeTitleTextFadesWithScrollAndCompactTitleCanReverse() async throws {
|
||||
guard !UIAccessibility.isReduceMotionEnabled else {
|
||||
throw XCTSkip("The system has disabled transition animation")
|
||||
}
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.rootViewController = parent
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
let messenger = NavigationTestMessenger()
|
||||
let bar = IosNavigationBarFactory(messenger: messenger, parent: parent).create(
|
||||
withFrame: CGRect(x: 0, y: 0, width: window.bounds.width, height: 180),
|
||||
viewIdentifier: 999992, arguments: ["title": "Home", "largeTitle": true])
|
||||
parent.view.addSubview(bar.view())
|
||||
parent.view.layoutIfNeeded()
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
let metrics = try XCTUnwrap(messenger.metrics)
|
||||
let expanded = try XCTUnwrap(metrics["expandedHeight"] as? Double)
|
||||
let compact = try XCTUnwrap(metrics["compactHeight"] as? Double)
|
||||
let boundary = expanded - compact
|
||||
|
||||
func labels(in view: UIView) -> [UILabel] {
|
||||
let own = (view as? UILabel).map { [$0] } ?? []
|
||||
return own + view.subviews.flatMap { labels(in: $0) }
|
||||
}
|
||||
func visibleOpacity(of view: UIView) -> Float {
|
||||
guard !view.isHidden else { return 0 }
|
||||
let opacity = view.layer.presentation()?.opacity ?? Float(view.alpha)
|
||||
let maskOpacity = view.layer.mask.map { $0.presentation()?.opacity ?? $0.opacity } ?? 1
|
||||
return opacity * maskOpacity * (view.superview.map { visibleOpacity(of: $0) } ?? 1)
|
||||
}
|
||||
let title = try XCTUnwrap(labels(in: navigation.view)
|
||||
.filter { $0.text == "Home" }.max { $0.font.pointSize < $1.font.pointSize })
|
||||
let centeredTitle = try XCTUnwrap(navigation.topViewController?.navigationItem.titleView?.subviews.compactMap { $0 as? UILabel }.first)
|
||||
let initialTitleY = title.convert(title.bounds, to: parent.view).minY
|
||||
XCTAssertEqual(title.textColor.cgColor.alpha, 1, accuracy: 0.01)
|
||||
messenger.scroll(to: boundary * 0.7)
|
||||
XCTAssertGreaterThan(title.textColor.cgColor.alpha, 0.1)
|
||||
XCTAssertLessThan(title.textColor.cgColor.alpha, 0.6, "The actual large text must fade before it collapses")
|
||||
// Flutter also resizes the platform view every scroll frame. Exercise that
|
||||
// layout, which could invalidate a snapshot-only header transition.
|
||||
bar.view().frame.size.height -= boundary * 0.7
|
||||
bar.view().setNeedsLayout()
|
||||
bar.view().layoutIfNeeded()
|
||||
XCTAssertLessThan(title.textColor.cgColor.alpha, 0.6)
|
||||
messenger.scroll(to: boundary)
|
||||
try await Task.sleep(nanoseconds: 60_000_000)
|
||||
XCTAssertFalse(try XCTUnwrap(centeredTitle.layer.mask).bounds.isEmpty)
|
||||
XCTAssertGreaterThan(visibleOpacity(of: centeredTitle), 0.01)
|
||||
XCTAssertLessThan(visibleOpacity(of: centeredTitle), 0.99)
|
||||
XCTAssertEqual(title.textColor.cgColor.alpha, 0, accuracy: 0.01)
|
||||
messenger.scroll(to: boundary * 0.7)
|
||||
try await Task.sleep(nanoseconds: 240_000_000)
|
||||
XCTAssertEqual(visibleOpacity(of: centeredTitle), 0, accuracy: 0.01)
|
||||
XCTAssertGreaterThan(title.textColor.cgColor.alpha, 0.1)
|
||||
messenger.scroll(to: boundary)
|
||||
try await Task.sleep(nanoseconds: 240_000_000)
|
||||
XCTAssertEqual(visibleOpacity(of: centeredTitle), 1, accuracy: 0.01)
|
||||
XCTAssertEqual(navigation.navigationBar.frame.height, compact, accuracy: 0.5)
|
||||
messenger.scroll(to: 0)
|
||||
bar.view().frame.size.height += boundary * 0.7
|
||||
bar.view().setNeedsLayout()
|
||||
bar.view().layoutIfNeeded()
|
||||
try await Task.sleep(nanoseconds: 240_000_000)
|
||||
XCTAssertEqual(title.textColor.cgColor.alpha, 1, accuracy: 0.01)
|
||||
XCTAssertEqual(visibleOpacity(of: centeredTitle), 0, accuracy: 0.01)
|
||||
XCTAssertEqual(navigation.navigationBar.frame.height, expanded, accuracy: 0.5)
|
||||
XCTAssertEqual(title.convert(title.bounds, to: parent.view).minY, initialTitleY, accuracy: 0.5)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testNativeNavigationTitleExpandsAgainAtTop() async throws {
|
||||
let messenger = NavigationTestMessenger()
|
||||
@@ -410,6 +589,20 @@ class RunnerTests: XCTestCase {
|
||||
let material = try XCTUnwrap(bar.view().subviews.first as? UIVisualEffectView)
|
||||
XCTAssertNotNil(material.effect)
|
||||
XCTAssertEqual(material.alpha, 0)
|
||||
if #available(iOS 27.0, *) {
|
||||
let scroll = try XCTUnwrap(navigation.topViewController?.view as? UIScrollView)
|
||||
XCTAssertTrue(scroll.topEdgeEffect.isHidden)
|
||||
let edge = try XCTUnwrap(bar.view().subviews.first {
|
||||
$0.accessibilityIdentifier == "navigation-status-edge"
|
||||
} as? UIScrollView)
|
||||
XCTAssertFalse(edge.isHidden)
|
||||
XCTAssertFalse(edge.topEdgeEffect.isHidden)
|
||||
XCTAssertEqual(edge.topEdgeEffect.style, .soft)
|
||||
XCTAssertEqual(edge.contentOffset, .zero)
|
||||
XCTAssertFalse(edge.isUserInteractionEnabled)
|
||||
XCTAssertTrue(material.isHidden)
|
||||
XCTAssertFalse(bar.view().clipsToBounds)
|
||||
}
|
||||
let expandedHeight = navigation.navigationBar.frame.height
|
||||
let metrics = try XCTUnwrap(messenger.metrics)
|
||||
let compactHeight = try XCTUnwrap(metrics["compactHeight"] as? Double)
|
||||
@@ -430,6 +623,13 @@ class RunnerTests: XCTestCase {
|
||||
try await Task.sleep(nanoseconds: 200_000_000)
|
||||
XCTAssertEqual(navigation.navigationBar.frame.height, expandedHeight, accuracy: 0.5)
|
||||
XCTAssertEqual(material.alpha, 0)
|
||||
if #available(iOS 27.0, *) {
|
||||
let edge = try XCTUnwrap(bar.view().subviews.first {
|
||||
$0.accessibilityIdentifier == "navigation-status-edge"
|
||||
} as? UIScrollView)
|
||||
XCTAssertFalse(edge.isHidden, "Returning to rest must not remove the status fade")
|
||||
XCTAssertEqual(edge.contentOffset, .zero)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -597,6 +597,11 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
final retention = ephemeralChannelDisplay(resolvedChannel);
|
||||
final nativeHeaderSubtitle = isOneToOneDm
|
||||
? dmHeader?.presenceLabel
|
||||
: headerMemberLabel;
|
||||
|
||||
final timelineMessages = resolvedChannel.isForum
|
||||
? <TimelineMessage>[]
|
||||
: formatTimeline(
|
||||
@@ -616,18 +621,22 @@ class ChannelDetailPage extends HookConsumerWidget {
|
||||
appBar: FrostedAppBar(
|
||||
alwaysFrosted: true,
|
||||
nativeViewSuppressed: messageActionBackdropActive,
|
||||
nativeEphemeralLabel: ephemeralChannelDisplay(
|
||||
resolvedChannel,
|
||||
)?.tooltipLabel,
|
||||
nativeTitle:
|
||||
dmHeader?.label ??
|
||||
resolveDmChannelDisplayLabel(
|
||||
resolvedChannel,
|
||||
currentPubkey: currentPubkey,
|
||||
),
|
||||
nativeSubtitle: isOneToOneDm
|
||||
? dmHeader?.presenceLabel
|
||||
: headerMemberLabel,
|
||||
nativeSubtitle: retention == null
|
||||
? nativeHeaderSubtitle
|
||||
: [
|
||||
'Temporary',
|
||||
if (retention.detailLabel != null) retention.detailLabel!,
|
||||
if (nativeHeaderSubtitle != null &&
|
||||
nativeHeaderSubtitle.isNotEmpty)
|
||||
nativeHeaderSubtitle,
|
||||
].join(' · '),
|
||||
nativeEphemeralLabel: retention?.tooltipLabel,
|
||||
nativeTitlePresenceColor: switch (isOneToOneDm
|
||||
? dmHeader?.presence
|
||||
: null) {
|
||||
|
||||
@@ -144,7 +144,7 @@ class FrostedAppBar extends StatelessWidget {
|
||||
/// surrounding scroll scope reports content beneath the controls.
|
||||
final bool frosted;
|
||||
|
||||
/// Keeps the Flutter backdrop and divider stable regardless of scroll state.
|
||||
/// Keeps the backdrop stable regardless of scroll state on both platforms.
|
||||
/// Conversation headers use this while their timeline and composer resize.
|
||||
final bool alwaysFrosted;
|
||||
|
||||
@@ -285,6 +285,7 @@ class FrostedAppBar extends StatelessWidget {
|
||||
titlePresenceColor: nativeTitlePresenceColor,
|
||||
onTitlePressed: onNativeTitlePressed,
|
||||
largeTitle: nativeLargeTitle,
|
||||
alwaysFrosted: alwaysFrosted,
|
||||
foregroundColor: iconColor,
|
||||
leading: nativeLeading ?? _nativeAction(leading),
|
||||
onBack:
|
||||
|
||||
@@ -116,6 +116,7 @@ class IosNavigationBar extends HookConsumerWidget {
|
||||
this.titlePresenceColor,
|
||||
this.onTitlePressed,
|
||||
this.largeTitle = false,
|
||||
this.alwaysFrosted = false,
|
||||
this.leading,
|
||||
this.actions = const [],
|
||||
this.onBack,
|
||||
@@ -132,6 +133,9 @@ class IosNavigationBar extends HookConsumerWidget {
|
||||
final Color? titlePresenceColor;
|
||||
final VoidCallback? onTitlePressed;
|
||||
final bool largeTitle;
|
||||
|
||||
/// Keeps the navigation backdrop visible before the first scroll update.
|
||||
final bool alwaysFrosted;
|
||||
final IosNavigationAction? leading;
|
||||
final List<IosNavigationAction> actions;
|
||||
final VoidCallback? onBack;
|
||||
@@ -229,6 +233,7 @@ class IosNavigationBar extends HookConsumerWidget {
|
||||
'titlePresenceColor': titlePresenceColor?.toARGB32(),
|
||||
'titleEnabled': onTitlePressed != null,
|
||||
'largeTitle': largeTitle,
|
||||
'alwaysFrosted': alwaysFrosted,
|
||||
'back': onBack != null,
|
||||
'leading': leading == null ? null : encodeAction(leading!, 'leading'),
|
||||
'actions': [
|
||||
|
||||
@@ -10128,7 +10128,7 @@ void main() {
|
||||
});
|
||||
|
||||
for (final dm in [false, true]) {
|
||||
testWidgets('native ephemeral header retains expiry disclosure dm=$dm', (
|
||||
testWidgets('native temporary title updates retention text dm=$dm', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
@@ -10168,8 +10168,9 @@ void main() {
|
||||
Map payload = view.creationParams! as Map;
|
||||
expect(
|
||||
payload['ephemeralLabel'],
|
||||
'Ephemeral channel. Cleans up after 1 hour of inactivity.',
|
||||
contains('after 1 hour of inactivity'),
|
||||
);
|
||||
expect(payload['subtitle'], startsWith('Temporary · 1h TTL'));
|
||||
const bridge = MethodChannel('buzz/ios_navigation_bar/297');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(bridge, (
|
||||
call,
|
||||
@@ -10191,14 +10192,13 @@ void main() {
|
||||
);
|
||||
container.invalidate(channelDetailsProvider(_channelId));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
payload['ephemeralLabel'],
|
||||
'Ephemeral channel. Cleanup is due now.',
|
||||
);
|
||||
expect(payload['ephemeralLabel'], contains('Cleanup is due now'));
|
||||
expect(payload['subtitle'], startsWith('Temporary · Cleanup due'));
|
||||
current = conversation();
|
||||
container.invalidate(channelDetailsProvider(_channelId));
|
||||
await tester.pumpAndSettle();
|
||||
expect(payload['ephemeralLabel'], isNull);
|
||||
expect(payload['subtitle'], isNot(contains('Temporary')));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
@@ -10,6 +10,34 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('iOS conversations request a backdrop before scrolling', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: const Stack(
|
||||
children: [
|
||||
FrostedAppBar(
|
||||
title: Text('Alice'),
|
||||
nativeSubtitle: 'Online',
|
||||
alwaysFrosted: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final view = tester.widget<UiKitView>(find.byType(UiKitView));
|
||||
expect(view.creationParams, containsPair('alwaysFrosted', true));
|
||||
expect(view.creationParams, containsPair('subtitle', 'Online'));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'conversation backdrop stays mounted across scroll and keyboard changes',
|
||||
(tester) async {
|
||||
|
||||
Reference in New Issue
Block a user