mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
### Summary Opening a channel on iOS 27.0.1 could terminate Buzz when a reversed, lazily loaded timeline reported an unbounded scroll extent. Flutter forwarded `Infinity` to the native navigation bar, and UIKit threw `CALayerInvalidGeometry` while setting the scroll view offset. Use the finite viewport depth for that unbounded case, preserving the scrolled-under appearance. Reject non-finite offsets at the native platform boundary as a final safety check. ### Validation - Added tests: a Flutter widget regression for an unbounded reversed timeline, and an iOS native regression for a non-finite platform-channel offset. - Reproduced the original crash by tapping `#general` on an iPhone SE running iOS 27.0.1. With this change, the channel rendered and survived repeated back-and-reopen cycles. Native logging confirmed the channel now sends finite offsets where it previously sent `Infinity`. - The native regression passed on iOS 26.5 and 27.0 simulators with Xcode 27.0. Flutter widget tests and `flutter analyze` passed. - A full channel-flow simulator run was not available: the isolated simulator Profile build could not resolve `BuzzPushKit`. The iOS 27.0.1 physical run exercised the full channel flow. - The full Flutter suite had eight notification and push test failures, all reproduced on the unchanged 0.24.0 source. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
797 lines
36 KiB
Swift
797 lines
36 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) }
|
|
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, actions[0]["plain"] as? Bool == true {
|
|
return makeItem(actions[0])
|
|
}
|
|
let surface = UIVisualEffectView()
|
|
if #available(iOS 26.0, *) {
|
|
let glass = UIGlassEffect(style: .regular)
|
|
glass.isInteractive = true
|
|
surface.effect = glass
|
|
} 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
|
|
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()
|
|
}
|
|
}
|