Skip to content

Commit 93b8b8f

Browse files
klopez4212Terp
andauthored
Standardize mobile navigation edges (#8895)
Standardizes mobile navigation headers: Android uses Home’s shared Flutter blur; iOS uses native soft edges, with graduated material retained during large-title collapse and the iOS 26 fallback preserved. Preserves Home’s upward title motion with a soft boundary and keeps the avatar crop title visible. Validation: 4,112 Flutter tests and 111 native tests passed after merging main. Rendered partial-scroll regression verified on the iOS 27 simulator; removing either review fix fails its regression assertion. The earlier preview was installed on a physical iPhone with the existing profile. --------- Signed-off-by: kenny lopez <[email protected]> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
1 parent 634f48a commit 93b8b8f

37 files changed

Lines changed: 907 additions & 621 deletions

‎mobile/ios/Runner.xcodeproj/project.pbxproj‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
2424
BZZ00000000000000000025 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
2525
DCAF00000000000000000001 /* IosNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAF00000000000000000002 /* IosNavigationBar.swift */; };
26+
DCAF00000000000000000031 /* NavigationTopEdge.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCAF00000000000000000032 /* NavigationTopEdge.swift */; };
2627
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
2728
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
2829
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
@@ -121,6 +122,7 @@
121122
BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
122123
BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = "<group>"; };
123124
DCAF00000000000000000002 /* IosNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IosNavigationBar.swift; sourceTree = "<group>"; };
125+
DCAF00000000000000000032 /* NavigationTopEdge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTopEdge.swift; sourceTree = "<group>"; };
124126
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
125127
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
126128
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
@@ -303,6 +305,7 @@
303305
BZZ0000000000000000002A /* PushSnapshotBridge.swift */,
304306
ACAF00000000000000000002 /* ToastLayer.swift */,
305307
DCAF00000000000000000002 /* IosNavigationBar.swift */,
308+
DCAF00000000000000000032 /* NavigationTopEdge.swift */,
306309
DAAB00000000000000000002 /* NativeTabler.swift */,
307310
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
308311
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
@@ -651,6 +654,7 @@
651654
BZZ00000000000000000029 /* PushSnapshotBridge.swift in Sources */,
652655
ACAF00000000000000000001 /* ToastLayer.swift in Sources */,
653656
DCAF00000000000000000001 /* IosNavigationBar.swift in Sources */,
657+
DCAF00000000000000000031 /* NavigationTopEdge.swift in Sources */,
654658
DAAB00000000000000000001 /* NativeTabler.swift in Sources */,
655659
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
656660
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,

‎mobile/ios/Runner/IosNavigationBar.swift‎

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -324,15 +324,13 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
324324
private let container: NavigationClipView
325325
private let material = UIVisualEffectView(effect: UIBlurEffect(style: .systemUltraThinMaterial))
326326
private let materialFade = CAGradientLayer()
327-
// A transparent, noninteractive viewport provides the system status fade
328-
// independently of the layout-only scroll view that collapses large titles.
329-
private let edgeScrollView = UIScrollView()
330327
private let content = NavigationContentController()
331328
private let navigation: UINavigationController
332329
private let channel: FlutterMethodChannel
333330
private var offset: CGFloat = 0
334331
private var controlBackground = UIColor.secondarySystemBackground
335332
private var alwaysFrosted = false
333+
private var backdropOnly = false
336334
private var usesSystemScrollEdge = false
337335
private var expandedBarHeight: CGFloat = 0
338336
private var titleIsCollapsed: Bool?
@@ -367,18 +365,6 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
367365
materialFade.locations = [0, 0.55, 1]
368366
material.layer.mask = materialFade
369367
container.addSubview(material)
370-
if #available(iOS 27.0, *) {
371-
edgeScrollView.accessibilityIdentifier = "navigation-status-edge"
372-
edgeScrollView.isUserInteractionEnabled = false
373-
edgeScrollView.scrollsToTop = false
374-
edgeScrollView.accessibilityElementsHidden = true
375-
edgeScrollView.backgroundColor = .clear
376-
edgeScrollView.contentInsetAdjustmentBehavior = .never
377-
edgeScrollView.contentSize = CGSize(width: 1, height: 10000)
378-
edgeScrollView.topEdgeEffect.style = .soft
379-
edgeScrollView.bottomEdgeEffect.isHidden = true
380-
container.addSubview(edgeScrollView)
381-
}
382368
navigation.view.backgroundColor = .clear
383369
navigation.navigationBar.isTranslucent = true
384370
parent?.addChild(navigation)
@@ -411,14 +397,19 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
411397
// needed for UIKit's scroll-edge and large-title calculations.
412398
guard !measuring else { return }
413399
let viewportHeight = container.window?.bounds.height ?? container.bounds.height
414-
edgeScrollView.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: viewportHeight)
415-
navigation.view.frame = edgeScrollView.frame
400+
navigation.view.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: viewportHeight)
416401
// Flutter's route can begin offscreen, where UIKit infers a zero safe area.
417402
// Keep the toolbar below the status region throughout that transition.
418403
// Read the unmodified host inset, never the inset we are adjusting.
419404
// During a slide UIKit may not yet have applied the previous adjustment.
420405
let automaticTop = container.safeAreaInsets.top
421-
let requiredTop = max(0, flutterSafeTop - automaticTop)
406+
// An empty native bar supplies the same edge geometry beneath a custom
407+
// Flutter header. Place its lower edge at the end of that header so the
408+
// system fade protects the filters as well as the status area.
409+
let protectedTop = backdropOnly
410+
? max(flutterSafeTop, container.bounds.height - max(44, navigation.navigationBar.bounds.height))
411+
: flutterSafeTop
412+
let requiredTop = max(0, protectedTop - automaticTop)
422413
if abs(navigation.additionalSafeAreaInsets.top - requiredTop) > 0.5 {
423414
navigation.additionalSafeAreaInsets.top = requiredTop
424415
}
@@ -473,35 +464,35 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
473464
flutterSafeTop = (args["safeTop"] as? NSNumber)?.doubleValue ?? 0
474465
navigation.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
475466
material.overrideUserInterfaceStyle = navigation.overrideUserInterfaceStyle
476-
edgeScrollView.overrideUserInterfaceStyle = navigation.overrideUserInterfaceStyle
477467
// Let the ultra-thin system material provide its own adaptive tint.
478468
material.contentView.backgroundColor = .clear
479469
let bar = navigation.navigationBar
480470
let color = Self.color(args["foreground"])
481471
bar.tintColor = color
482472
titleColor = color
483473
largeTitleOpacity = -1
474+
backdropOnly = args["backdropOnly"] as? Bool == true
475+
container.isUserInteractionEnabled = !backdropOnly
476+
navigation.view.isUserInteractionEnabled = !backdropOnly
477+
navigation.view.accessibilityElementsHidden = backdropOnly
484478
let largeTitle = args["largeTitle"] as? Bool == true
485479
alwaysFrosted = args["alwaysFrosted"] as? Bool == true
486480
if #available(iOS 26.0, *) {
487481
if #available(iOS 27.0, *) { usesSystemScrollEdge = true }
488482
content.scrollView.topEdgeEffect.style = .soft
489-
content.scrollView.topEdgeEffect.isHidden = !usesSystemScrollEdge || !alwaysFrosted || largeTitle
490-
edgeScrollView.isHidden = alwaysFrosted && !largeTitle
483+
content.scrollView.topEdgeEffect.isHidden = !usesSystemScrollEdge
491484
}
492-
// Large-title pages need material across the whole header: the stationary
493-
// system edge only blurs the status area, leaving expanded text over rows.
494-
// iOS 26 does not composite this native edge over sibling Flutter content
495-
// in our embedding, so retain the ultra-thin material fallback there.
496-
// Conversations use the navigation controller's native edge. Other pages
497-
// use a stationary native edge so UIKit's large-title inset adjustments
498-
// cannot hide the status-area fade when the title expands or collapses.
485+
// Older embedded views retain material because their native edge cannot
486+
// sample Flutter content. On iOS 27, UIKit also suppresses its native
487+
// edge while the large-title inset is collapsing.
488+
// Keep the graduated material throughout that transition so partial
489+
// scrolling never leaves the title and status area unprotected.
499490
material.isHidden = usesSystemScrollEdge && !largeTitle
500491
// Let the native fade extend past the bar without enlarging its hit area.
501492
// The mirrored content is transparent and the bottom edge is hidden.
502493
container.clipsToBounds = !usesSystemScrollEdge
503494
let appearance = UINavigationBarAppearance()
504-
if usesSystemScrollEdge && !largeTitle {
495+
if usesSystemScrollEdge {
505496
appearance.configureWithDefaultBackground()
506497
} else {
507498
appearance.configureWithTransparentBackground()

‎mobile/ios/Runner/NativeAddMembersSheet.swift‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,7 @@ final class NativeAddMembersViewController: UITableViewController, UISearchResul
142142
definesPresentationContext = true
143143
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancel))
144144
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Add", style: .done, target: self, action: #selector(add))
145-
let appearance = UINavigationBarAppearance()
146-
appearance.configureWithOpaqueBackground()
147-
appearance.backgroundColor = pageColor
148-
appearance.shadowColor = .clear
149-
navigationItem.standardAppearance = appearance
150-
navigationItem.scrollEdgeAppearance = appearance
145+
NavigationTopEdge.configure(tableView, item: navigationItem)
151146
applyState()
152147
}
153148

‎mobile/ios/Runner/NativeCommunityEntry.swift‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,17 @@ private final class NativeCommunityEntryController: UIViewController, UITextFiel
118118
navigationItem.rightBarButtonItem = UIBarButtonItem(systemItem: .close,
119119
primaryAction: UIAction { [weak self] _ in self?.close() })
120120
if #available(iOS 17.0, *) { view.keyboardLayoutGuide.usesBottomSafeArea = false }
121-
scroll.contentInsetAdjustmentBehavior = .never
121+
scroll.contentInsetAdjustmentBehavior = .always
122122
scroll.translatesAutoresizingMaskIntoConstraints = false
123123
stack.translatesAutoresizingMaskIntoConstraints = false
124124
stack.axis = .vertical
125125
stack.spacing = 12
126+
NavigationTopEdge.configure(scroll, item: navigationItem)
127+
if #available(iOS 15.0, *) { setContentScrollView(scroll) }
126128
view.addSubview(scroll)
127129
scroll.addSubview(stack)
128130
NSLayoutConstraint.activate([
129-
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
131+
scroll.topAnchor.constraint(equalTo: view.topAnchor),
130132
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
131133
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
132134
scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),

‎mobile/ios/Runner/NativeProfileSetup.swift‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,11 +342,21 @@ private final class NativeProfileSetupController: UIViewController, UITextFieldD
342342
view.tintColor = UIColor(red: CGFloat((argb >> 16) & 255) / 255, green: CGFloat((argb >> 8) & 255) / 255, blue: CGFloat(argb & 255) / 255, alpha: 1)
343343
let stack = UIStackView(); stack.axis = .vertical; stack.spacing = 16; stack.translatesAutoresizingMaskIntoConstraints = false
344344
let scroll = UIScrollView(); scroll.translatesAutoresizingMaskIntoConstraints = false
345+
NavigationTopEdge.configure(scroll, item: navigationItem)
346+
if #available(iOS 15.0, *) { setContentScrollView(scroll) }
345347
view.addSubview(scroll); scroll.addSubview(stack)
348+
// Content keeps its original keyboard-aware height while the scroll view
349+
// extends beneath navigation for the system soft edge.
350+
let contentViewport = UILayoutGuide()
351+
view.addLayoutGuide(contentViewport)
346352
NSLayoutConstraint.activate([
347-
scroll.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
353+
contentViewport.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
354+
contentViewport.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
355+
contentViewport.leadingAnchor.constraint(equalTo: view.leadingAnchor),
356+
contentViewport.trailingAnchor.constraint(equalTo: view.trailingAnchor),
357+
scroll.topAnchor.constraint(equalTo: view.topAnchor), scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),
348358
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor), scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
349-
stack.heightAnchor.constraint(greaterThanOrEqualTo: scroll.frameLayoutGuide.heightAnchor, constant: -48),
359+
stack.heightAnchor.constraint(greaterThanOrEqualTo: contentViewport.heightAnchor, constant: -48),
350360
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 24), stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -24),
351361
stack.leadingAnchor.constraint(equalTo: scroll.frameLayoutGuide.leadingAnchor, constant: 24), stack.trailingAnchor.constraint(equalTo: scroll.frameLayoutGuide.trailingAnchor, constant: -24)
352362
])

‎mobile/ios/Runner/NativeProfileTextEditor.swift‎

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -370,18 +370,7 @@ private final class NativeProfileTextEditorViewController:
370370
tableView.backgroundColor = pageBackgroundColor
371371
tableView.keyboardDismissMode = .interactive
372372
tableView.alwaysBounceVertical = false
373-
let navigationAppearance = UINavigationBarAppearance()
374-
navigationAppearance.configureWithOpaqueBackground()
375-
navigationAppearance.backgroundColor = pageBackgroundColor
376-
navigationAppearance.shadowColor = UIColor.separator.withAlphaComponent(0.2)
377-
let scrollEdgeAppearance = UINavigationBarAppearance()
378-
scrollEdgeAppearance.configureWithOpaqueBackground()
379-
scrollEdgeAppearance.backgroundColor = pageBackgroundColor
380-
scrollEdgeAppearance.shadowColor = .clear
381-
navigationItem.standardAppearance = navigationAppearance
382-
navigationItem.scrollEdgeAppearance = scrollEdgeAppearance
383-
navigationItem.compactAppearance = navigationAppearance
384-
navigationItem.compactScrollEdgeAppearance = scrollEdgeAppearance
373+
NavigationTopEdge.configure(tableView, item: navigationItem)
385374
navigationItem.leftBarButtonItem = UIBarButtonItem(
386375
barButtonSystemItem: .cancel,
387376
target: self,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import UIKit
2+
3+
/// Applies the app's soft navigation edge to app-owned native sheets.
4+
/// OS-owned pickers retain their system presentation.
5+
enum NavigationTopEdge {
6+
static func configure(_ scrollView: UIScrollView, item: UINavigationItem) {
7+
if #available(iOS 26.0, *) {
8+
scrollView.topEdgeEffect.style = .soft
9+
scrollView.bottomEdgeEffect.isHidden = true
10+
}
11+
let appearance = UINavigationBarAppearance()
12+
appearance.configureWithDefaultBackground()
13+
item.standardAppearance = appearance
14+
item.scrollEdgeAppearance = appearance
15+
item.compactAppearance = appearance
16+
item.compactScrollEdgeAppearance = appearance
17+
}
18+
}

0 commit comments

Comments
 (0)