Files
buzz/mobile/ios/Runner/IosNavigationBar.swift
klopez4212 27b9c827fc Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing
avatar gutter, and match desktop calendar dividers with balanced
spacing. Standardize navigation and sheet icons at 24 points, restore
native Edit/Done styling, and align section menus with trailing channel
indicators.

Validation: clean analysis; 3,513 mobile tests and push-configuration
checks passed; iPhone Release build installed and tested by the owner.
Includes regression coverage for message placement, date spacing,
gallery direction, and section alignment.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-18 15:17:25 -04:00

813 lines
37 KiB
Swift

import Flutter
import UIKit
/// Each Flutter route owns a UIKit navigation controller. Its private scroll
/// view mirrors the Flutter viewport so UIKit performs the large-title layout.
final class IosNavigationBarFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parent: UIViewController?
init(messenger: FlutterBinaryMessenger, parent: UIViewController?) {
self.messenger = messenger
self.parent = parent
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(withFrame frame: CGRect, viewIdentifier viewId: Int64, arguments args: Any?) -> FlutterPlatformView {
IosNavigationBarView(frame: frame, id: viewId, args: args, messenger: messenger, parent: parent)
}
}
final class NavigationTitleView: UIVisualEffectView, UIGestureRecognizerDelegate {
var onActivate: (() -> Void)?
private let titleLabel = UILabel()
private let subtitleLabel = UILabel()
private let contentStack = UIStackView()
private let textStack = UIStackView()
private let subtitleStack = UIStackView()
init(title: String?, subtitle: String, color: UIColor) {
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.textColor = color
subtitleLabel.text = subtitle
subtitleLabel.textColor = .secondaryLabel
for label in [titleLabel, subtitleLabel] {
label.textAlignment = .center
label.lineBreakMode = .byTruncatingTail
label.adjustsFontForContentSizeCategory = false
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
subtitleStack.axis = .horizontal
subtitleStack.alignment = .center
subtitleStack.spacing = 6
subtitleStack.addArrangedSubview(subtitleLabel)
textStack.axis = .vertical
textStack.alignment = .center
textStack.addArrangedSubview(titleLabel)
textStack.addArrangedSubview(subtitleStack)
contentStack.axis = .horizontal
contentStack.alignment = .center
contentStack.spacing = 8
contentStack.addArrangedSubview(textStack)
contentStack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(contentStack)
NSLayoutConstraint.activate([
contentStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12),
contentStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
contentStack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
])
translatesAutoresizingMaskIntoConstraints = false
updateFonts()
// Flutter creates platform views with a zero frame. Keep a nonzero
// intrinsic width and let UINavigationBar compress it between its items.
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
isAccessibilityElement = true
accessibilityTraits = .button
let tap = UITapGestureRecognizer(target: self, action: #selector(activate))
tap.delegate = self
addGestureRecognizer(tap)
}
func setAvatar(_ image: UIImage?, presence: UIColor?) {
// Bar-item images carry alignment insets; the title owns its 32pt layout.
let avatar = UIImageView(image: image?.withAlignmentRectInsets(.zero))
avatar.accessibilityIdentifier = "dm-navigation-avatar"
avatar.contentMode = .scaleAspectFit
let avatarContainer = UIView()
avatar.translatesAutoresizingMaskIntoConstraints = false
avatarContainer.addSubview(avatar)
contentStack.insertArrangedSubview(avatarContainer, at: 0)
NSLayoutConstraint.activate([
avatarContainer.widthAnchor.constraint(equalToConstant: 32),
avatarContainer.heightAnchor.constraint(equalToConstant: 32),
avatar.leadingAnchor.constraint(equalTo: avatarContainer.leadingAnchor),
avatar.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
avatar.topAnchor.constraint(equalTo: avatarContainer.topAnchor),
avatar.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
])
textStack.alignment = .leading
for label in [titleLabel, subtitleLabel] { label.textAlignment = .natural }
if let presence {
let badge = UIView()
badge.backgroundColor = presence
badge.layer.cornerRadius = 4
badge.layer.borderWidth = 1.5
badge.layer.borderColor = UIColor.systemBackground.cgColor
badge.accessibilityIdentifier = "dm-navigation-presence"
badge.translatesAutoresizingMaskIntoConstraints = false
avatarContainer.addSubview(badge)
NSLayoutConstraint.activate([
badge.widthAnchor.constraint(equalToConstant: 8),
badge.heightAnchor.constraint(equalToConstant: 8),
badge.trailingAnchor.constraint(equalTo: avatarContainer.trailingAnchor),
badge.bottomAnchor.constraint(equalTo: avatarContainer.bottomAnchor),
])
}
invalidateIntrinsicContentSize()
}
func setSubtitlePresence(_ color: UIColor) {
let dot = UIView()
dot.backgroundColor = color
dot.layer.cornerRadius = 3
dot.isAccessibilityElement = false
dot.accessibilityIdentifier = "dm-navigation-status-dot"
subtitleStack.insertArrangedSubview(dot, at: 0)
NSLayoutConstraint.activate([
dot.widthAnchor.constraint(equalToConstant: 6),
dot.heightAnchor.constraint(equalToConstant: 6),
])
invalidateIntrinsicContentSize()
}
func setEphemeralStatus(_ label: String) {
// 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: ", ")
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
acceptsTitleTouch(in: touch.view)
}
// UIKit can wrap titleView in a UIControl. Only controls inside our title
// should consume the title tap.
func acceptsTitleTouch(in touchedView: UIView?) -> Bool {
var touched = touchedView
while let view = touched, view !== self {
if view is UIControl { return false }
touched = view.superview
}
return true
}
required init?(coder: NSCoder) { return nil }
@objc private func activate() { onActivate?() }
override func accessibilityActivate() -> Bool {
guard isUserInteractionEnabled, let onActivate else { return false }
onActivate()
return true
}
override var intrinsicContentSize: CGSize {
let contentSize = contentStack.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return CGSize(width: contentSize.width + 24, height: max(44, contentSize.height))
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory {
updateFonts()
invalidateIntrinsicContentSize()
setNeedsLayout()
}
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let desired = intrinsicContentSize
return CGSize(width: min(size.width, desired.width), height: desired.height)
}
private func updateFonts() {
// 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(
for: .systemFont(ofSize: 17, weight: .semibold), maximumPointSize: 20,
compatibleWith: traitCollection)
subtitleLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(
for: .systemFont(ofSize: 12), maximumPointSize: 14,
compatibleWith: traitCollection)
}
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
}
private final class NavigationClipView: UIView {
var onLayout: (() -> Void)?
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
setNeedsLayout()
}
override func layoutSubviews() {
super.layoutSubviews()
onLayout?()
}
}
private final class NavigationContentController: UIViewController {
let scrollView = UIScrollView()
override func loadView() {
view = scrollView
scrollView.contentSize = CGSize(width: 1, height: 10000)
scrollView.isUserInteractionEnabled = false
scrollView.scrollsToTop = false
scrollView.backgroundColor = .clear
if #available(iOS 26.0, *) {
// 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
}
}
}
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 controlBackground = UIColor.secondarySystemBackground
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?
private var measuring = false
private var metrics: [String: Any]?
private weak var trackedAvatar: UIButton?
private var reportedAvatarFrame: CGRect?
private var flutterSafeTop: CGFloat = 0
init(frame: CGRect, id: Int64, args: Any?, messenger: FlutterBinaryMessenger, parent: UIViewController?) {
container = NavigationClipView(frame: frame)
navigation = UINavigationController(rootViewController: content)
channel = FlutterMethodChannel(name: "buzz/ios_navigation_bar/\(id)", binaryMessenger: messenger)
super.init()
container.clipsToBounds = true
container.backgroundColor = .clear
material.isUserInteractionEnabled = false
material.accessibilityIdentifier = "navigation-scroll-material"
material.alpha = 0
// Keep the scroll-edge backdrop light and let it fade into the page
// instead of drawing a uniformly frosted rectangular toolbar.
materialFade.colors = [UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor]
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.scrollsToTop = 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)
container.addSubview(navigation.view)
navigation.didMove(toParent: parent)
container.onLayout = { [weak self] in self?.layout() }
channel.setMethodCallHandler { [weak self] call, result in
switch call.method {
case "configure": self?.configure(call.arguments as? [String: Any] ?? [:])
case "prepareForReveal":
UIView.performWithoutAnimation {
self?.navigation.view.setNeedsLayout()
self?.navigation.navigationBar.setNeedsLayout()
self?.container.setNeedsLayout()
self?.container.layoutIfNeeded()
}
case "scroll":
self?.setScrollOffset((call.arguments as? NSNumber)?.doubleValue ?? 0)
default: result(FlutterMethodNotImplemented); return
}
result(nil)
}
configure(args as? [String: Any] ?? [:])
}
func view() -> UIView { container }
private func layout() {
// 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 }
let viewportHeight = container.window?.bounds.height ?? container.bounds.height
edgeScrollView.frame = CGRect(x: 0, y: 0, width: container.bounds.width, height: viewportHeight)
navigation.view.frame = edgeScrollView.frame
// Flutter's route can begin offscreen, where UIKit infers a zero safe area.
// Keep the toolbar below the status region throughout that transition.
// Read the unmodified host inset, never the inset we are adjusting.
// During a slide UIKit may not yet have applied the previous adjustment.
let automaticTop = container.safeAreaInsets.top
let requiredTop = max(0, flutterSafeTop - automaticTop)
if abs(navigation.additionalSafeAreaInsets.top - requiredTop) > 0.5 {
navigation.additionalSafeAreaInsets.top = requiredTop
}
navigation.view.setNeedsLayout()
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()
}
private func reportAvatarBounds() {
guard let avatar = trackedAvatar, avatar.window != nil, avatar.bounds.width > 0 else { return }
avatar.layoutIfNeeded()
guard let image = avatar.imageView, image.bounds.width > 0 else { return }
let frame = image.convert(image.bounds, to: container)
guard frame != reportedAvatarFrame else { return }
reportedAvatarFrame = frame
DispatchQueue.main.async { [weak self] in
self?.channel.invokeMethod("avatarBounds", arguments: [
"id": "leading", "x": frame.minX, "y": frame.minY,
"width": frame.width, "height": frame.height
])
}
}
private func configure(_ args: [String: Any]) {
controlBackground = Self.color(args["controlBackground"])
flutterSafeTop = (args["safeTop"] as? NSNumber)?.doubleValue ?? 0
navigation.overrideUserInterfaceStyle = args["dark"] as? Bool == true ? .dark : .light
material.overrideUserInterfaceStyle = navigation.overrideUserInterfaceStyle
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
}
// Large-title pages need material across the whole header: the stationary
// system edge only blurs the status area, leaving expanded text over rows.
// 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 && !largeTitle
// 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()
if usesSystemScrollEdge && !largeTitle {
appearance.configureWithDefaultBackground()
} else {
appearance.configureWithTransparentBackground()
}
appearance.titleTextAttributes = [.foregroundColor: color]
appearance.largeTitleTextAttributes = [.foregroundColor: color]
bar.standardAppearance = appearance
bar.scrollEdgeAppearance = appearance
bar.compactAppearance = appearance
if bar.prefersLargeTitles != largeTitle {
measuredWidth = 0
titleIsCollapsed = nil
}
bar.prefersLargeTitles = largeTitle
// 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 {
let button = NavigationTitleView(title: item.title, subtitle: subtitle, color: color)
button.accessibilityIdentifier = "channel-navigation-title"
let enabled = args["titleEnabled"] as? Bool == true
button.accessibilityLabel = enabled
? "Open settings for \(item.title ?? ""), \(subtitle)"
: "\(item.title ?? ""), \(subtitle)"
button.accessibilityTraits = enabled ? .button : .header
button.isUserInteractionEnabled = enabled
if enabled {
button.onActivate = { [weak self] in self?.channel.invokeMethod("action", arguments: "title") }
}
if let avatar = args["titleAvatar"] as? [String: Any] {
button.setAvatar(makeItem(avatar).image,
presence: args["titlePresenceColor"] is NSNumber ? Self.color(args["titlePresenceColor"]) : nil)
}
if args["titleAvatar"] as? [String: Any] == nil, args["titlePresenceColor"] is NSNumber {
button.setSubtitlePresence(Self.color(args["titlePresenceColor"]))
}
if let label = args["ephemeralLabel"] as? String {
button.setEphemeralStatus(label)
}
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
}
item.largeTitleDisplayMode = bar.prefersLargeTitles ? .always : .never
trackedAvatar = nil
reportedAvatarFrame = nil
if let leading = args["leading"] as? [String: Any] {
item.leftBarButtonItems = [makeControlGroup([leading])]
} else if args["back"] as? Bool == true {
item.leftBarButtonItems = [makeControlGroup([["id": "back", "label": "Back", "symbol": "chevron.backward", "enabled": true]])]
} else {
item.leftBarButtonItems = nil
}
var groups: [[[String: Any]]] = []
for action in args["actions"] as? [[String: Any]] ?? [] {
let shares = action["sharesBackground"] as? Bool ?? true
if shares, let previous = groups.last?.last,
previous["sharesBackground"] as? Bool ?? true,
previous["plain"] as? Bool != true, action["plain"] as? Bool != true {
groups[groups.count - 1].append(action)
} else {
groups.append([action])
}
}
item.rightBarButtonItems = groups.reversed().map(makeControlGroup)
if let metrics {
channel.invokeMethod("metrics", arguments: metrics)
}
container.setNeedsLayout()
}
private func measureIfNeeded() {
guard container.window != nil, container.bounds.width > 0 else { return }
let category = navigation.traitCollection.preferredContentSizeCategory
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
// Measure UIKit's actual compact and expanded layouts before presenting
// this frame. Flutter reserves these measured dimensions, never the other
// way around. Recalculate after rotation or a Dynamic Type change.
bar.prefersLargeTitles = false
content.navigationItem.largeTitleDisplayMode = .never
navigation.view.setNeedsLayout()
navigation.view.layoutIfNeeded()
let compact = bar.frame.height
bar.prefersLargeTitles = large
content.navigationItem.largeTitleDisplayMode = large ? .always : .never
content.scrollView.setContentOffset(CGPoint(x: 0, y: -1000), animated: false)
navigation.view.setNeedsLayout()
navigation.view.layoutIfNeeded()
expandedBarHeight = bar.frame.height
measuredWidth = container.bounds.width
measuredSafeTop = safeTop
measuredCategory = category
var metrics: [String: Any] = ["compactHeight": compact]
if large { metrics["expandedHeight"] = expandedBarHeight }
self.metrics = metrics
DispatchQueue.main.async { [weak self] in
self?.channel.invokeMethod("metrics", arguments: metrics)
}
}
private func setScrollOffset(_ value: CGFloat) {
guard value.isFinite else { return }
offset = max(0, value)
material.alpha = alwaysFrosted ? 1 : min(1, offset / 12)
applyScroll()
reportAvatarBounds()
}
private func applyScroll() {
let scroll = content.scrollView
guard container.window != nil else { return }
// UIKit reduces adjustedContentInset as the title collapses. Using that
// moving inset as zero leaves the title collapsed when Flutter returns to
// the top. Keep zero anchored to the expanded bar, including the current
// safe area, throughout the scroll cycle.
expandedBarHeight = max(expandedBarHeight, navigation.navigationBar.frame.height)
let topInset = navigation.view.safeAreaInsets.top + expandedBarHeight
// 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")
}
}
}
}
private func makeAction(_ data: [String: Any]) -> UIAction {
let id = data["id"] as? String ?? ""
let enabled = data["enabled"] as? Bool == true
let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0, size: 24) }
return UIAction(title: data["label"] as? String ?? "", image: symbol,
attributes: enabled ? [] : [.disabled],
state: data["selected"] as? Bool == true ? .on : .off) { [weak self] _ in
self?.channel.invokeMethod("action", arguments: id)
}
}
private func makeControlGroup(_ actions: [[String: Any]]) -> UIBarButtonItem {
if actions.count == 1 {
let item = makeItem(actions[0])
// Let UIKit retain its navigation typography and sizing for text actions
// such as Edit/Done, rather than inheriting UIButton's default styling.
if actions[0]["plain"] as? Bool == true || (item.image == nil && item.customView == nil) {
return item
}
}
let surface = UIVisualEffectView()
if #available(iOS 26.0, *) {
let glass = UIGlassEffect(style: .regular)
glass.isInteractive = true
surface.effect = glass
surface.cornerConfiguration = .capsule()
} else {
surface.backgroundColor = controlBackground
}
// Match the navigation bar's 44-point content height so single
// controls remain circular rather than being compressed vertically.
surface.layer.cornerRadius = 22
surface.layer.cornerCurve = .circular
surface.clipsToBounds = true
let stack = UIStackView()
stack.axis = .horizontal
stack.translatesAutoresizingMaskIntoConstraints = false
surface.contentView.addSubview(stack)
for data in actions {
let item = makeItem(data)
let button: UIButton
if let avatar = item.customView as? UIButton {
button = avatar
} else {
button = UIButton(type: .system)
button.setImage(item.image, for: .normal)
if item.image == nil { button.setTitle(item.title, for: .normal) }
if let action = item.primaryAction { button.addAction(action, for: .touchUpInside) }
button.menu = item.menu
button.showsMenuAsPrimaryAction = item.menu != nil
if item.menu != nil {
// UIKit lifts the button itself for its menu transition, not the
// enclosing glass. Give that return target the same rounded shape.
if #available(iOS 26.0, *) {
button.cornerConfiguration = .capsule()
}
button.layer.cornerRadius = 22
button.layer.cornerCurve = .circular
button.clipsToBounds = true
}
button.isEnabled = item.isEnabled
button.accessibilityLabel = item.accessibilityLabel
button.accessibilityValue = item.accessibilityValue
}
button.tintColor = navigation.navigationBar.tintColor
let slot = UIView()
slot.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
slot.widthAnchor.constraint(equalToConstant: item.image == nil && item.customView == nil ? max(44, button.intrinsicContentSize.width + 16) : 44),
slot.heightAnchor.constraint(equalToConstant: 44),
button.centerXAnchor.constraint(equalTo: slot.centerXAnchor),
button.centerYAnchor.constraint(equalTo: slot.centerYAnchor)
])
if item.customView == nil {
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalTo: slot.widthAnchor),
button.heightAnchor.constraint(equalTo: slot.heightAnchor)
])
}
stack.addArrangedSubview(slot)
}
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: surface.contentView.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: surface.contentView.trailingAnchor),
stack.topAnchor.constraint(equalTo: surface.contentView.topAnchor),
stack.bottomAnchor.constraint(equalTo: surface.contentView.bottomAnchor)
])
let item = UIBarButtonItem(customView: surface)
if #available(iOS 26.0, *) { item.hidesSharedBackground = true }
return item
}
private func makeItem(_ data: [String: Any]) -> UIBarButtonItem {
let action = makeAction(data)
let children = data["children"] as? [[String: Any]] ?? []
let item = children.isEmpty
? UIBarButtonItem(primaryAction: action)
: UIBarButtonItem(title: action.title, image: action.image, primaryAction: nil,
menu: UIMenu(children: children.map(makeAction)))
if #available(iOS 26.0, *) {
item.hidesSharedBackground = data["plain"] as? Bool == true
item.sharesBackground = data["sharesBackground"] as? Bool ?? true
}
item.accessibilityLabel = data["label"] as? String
item.isEnabled = data["enabled"] as? Bool == true
if data["avatarInitial"] is String { item.title = nil }
if let encoded = data["iconData"] as? String,
let bytes = Data(base64Encoded: encoded), let image = UIImage(data: bytes) {
item.image = UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in
image.draw(in: CGRect(x: 0, y: 0, width: 24, height: 24))
}.withRenderingMode(.alwaysTemplate)
item.title = nil
}
if let encoded = data["imageData"] as? String,
let bytes = Data(base64Encoded: encoded), let image = UIImage(data: bytes) {
// Character artwork floats on the glass; photos fill its circular face.
// Preserve the 24-point alignment footprint so UIKit does not widen it.
let diameter: CGFloat = data["avatarIsAgent"] as? Bool == true && data["avatarArtwork"] as? Bool != true ? 44 : 36
let inset = (diameter - 24) / 2
let size = CGSize(width: diameter, height: diameter)
item.image = UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}.withRenderingMode(.alwaysOriginal).withAlignmentRectInsets(
UIEdgeInsets(top: inset, left: inset, bottom: inset, right: inset)
)
}
if item.image == nil, let initial = data["avatarInitial"] as? String {
// An avatar-shaped placeholder is available synchronously, before
// Flutter finishes decoding the photo. Never fall back to a glyph icon.
let size = CGSize(width: 36, height: 36)
item.title = nil
item.image = UIGraphicsImageRenderer(size: size).image { _ in
Self.color(data["avatarBackground"]).setFill()
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size),
cornerRadius: size.width * 0.5).fill()
let text = initial as NSString
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 16, weight: .medium),
.foregroundColor: Self.color(data["avatarForeground"])
]
let textSize = text.size(withAttributes: attributes)
text.draw(at: CGPoint(x: (size.width - textSize.width) / 2,
y: (size.height - textSize.height) / 2), withAttributes: attributes)
}.withRenderingMode(.alwaysOriginal).withAlignmentRectInsets(
UIEdgeInsets(top: 6, left: 6, bottom: 6, right: 6)
)
}
if data["activityColor"] is NSNumber, let symbol = item.image {
let size = CGSize(width: 28, height: 26)
item.image = UIGraphicsImageRenderer(size: size).image { _ in
symbol.withTintColor(navigation.navigationBar.tintColor).draw(in: CGRect(x: 0, y: 4, width: 22, height: 22))
UIColor.systemBackground.setFill()
UIBezierPath(ovalIn: CGRect(x: 18, y: 0, width: 10, height: 10)).fill()
Self.color(data["activityColor"]).setFill()
UIBezierPath(ovalIn: CGRect(x: 19.5, y: 1.5, width: 7, height: 7)).fill()
}.withRenderingMode(.alwaysOriginal)
item.accessibilityValue = data["activityLabel"] as? String
}
if data["avatarInitial"] is String, children.isEmpty {
// An explicit native button gives Flutter a public, measured destination
// without depending on UINavigationBar's private view hierarchy.
let button = UIButton(type: .custom)
// UIKit adds the glass button's own padding around this custom view.
// Match the 36-point image on both axes so that glass stays circular.
button.frame = CGRect(x: 0, y: 0, width: 36, height: 36)
button.widthAnchor.constraint(equalToConstant: 36).isActive = true
button.heightAnchor.constraint(equalToConstant: 36).isActive = true
button.setImage(item.image?.withAlignmentRectInsets(.zero), for: .normal)
button.addAction(action, for: .touchUpInside)
button.isEnabled = item.isEnabled
button.accessibilityLabel = item.accessibilityLabel
button.accessibilityIdentifier = data["id"] as? String == "leading"
? "community-navigation-avatar" : "profile-navigation-avatar"
let hidden = data["avatarHidden"] as? Bool == true
button.alpha = hidden ? 0 : 1
button.accessibilityElementsHidden = hidden
if data["tracksAvatarBounds"] as? Bool == true, data["id"] as? String == "leading" {
trackedAvatar = button
}
// Both edge avatars use the same footprint and native glass padding,
// keeping their containers aligned to UIKit's symmetric navigation gutter.
let avatarItem = UIBarButtonItem(customView: button)
if #available(iOS 26.0, *) {
avatarItem.hidesSharedBackground = data["plain"] as? Bool == true
avatarItem.sharesBackground = data["sharesBackground"] as? Bool ?? true
}
return avatarItem
}
return item
}
private static func color(_ value: Any?) -> UIColor {
guard let argb = (value as? NSNumber)?.uint32Value else { return .label }
return UIColor(red: CGFloat((argb >> 16) & 255) / 255,
green: CGFloat((argb >> 8) & 255) / 255,
blue: CGFloat(argb & 255) / 255, alpha: CGFloat(argb >> 24) / 255)
}
deinit {
channel.setMethodCallHandler(nil)
navigation.willMove(toParent: nil)
navigation.view.removeFromSuperview()
navigation.removeFromParent()
}
}