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:
klopez4212
2026-10-05 11:55:11 -04:00
committed by GitHub
parent 46e60206a4
commit 8746bfebff
7 changed files with 483 additions and 130 deletions

View File

@@ -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")
}
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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) {

View File

@@ -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:

View File

@@ -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': [

View File

@@ -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;
});

View File

@@ -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 {