宝贝计划

问题背景

宝贝计划的 iOS 首页和“时光”页支持用户设置自定义背景图。背景图叠加上去之后,页面的核心内容需要保持可读;其中最容易出问题的是导航栏 Large Title(展开态的大标题)。

默认情况下,系统 Large Title 的前景色由当前 ColorScheme 决定:浅色模式下是黑色,深色模式下是白色。它并不会“看”背后的背景图是什么颜色。如果用户选了一张浅色背景图,而 App 又处于浅色模式,标题应该是黑色——这没问题;但如果 App 的导航栏背景被隐藏、让图片直接透出来,系统的标题颜色逻辑并不会因为图片是浅色就变成黑色,结果常常是标题看不见。

这次要解决的问题就是:在显示自定义背景图时,让 Large Title 始终可读;不使用自创的图片像素亮度采样,也不破坏背景图的透传效果。

之前是怎么做的

早期实现采用了一套自定义亮度采样机制:

// 伪代码
let map = BackgroundLuminanceMap(image: image)
let luminance = map.averageLuminance(in: titleFrame)
let isDarkBackground = luminance < threshold
let titleColor: Color = isDarkBackground ? .white : .black
  1. 把用户选择的图片生成一张缩略亮度图。
  2. 估算 Large Title 所在区域,取该区域平均亮度。
  3. 亮度低于阈值 → 判定为深色背景 → 标题用白色;反之用黑色。
  4. 通过 UIKit 的 UINavigationBarAppearance.largeTitleTextAttributes 把颜色写进导航栏。

这套方案能跑通一部分场景,但本质上是“自己算颜色”,不是系统原生行为。用户后续明确要求:不要自创采样方式,要用原生方案。

过程中的几种方案与失败原因

在放弃自定义采样之后,我们尝试了若干纯原生路径,几乎每一步都被 SwiftUI 与 UIKit 的边界问题挡回来。下面是主要尝试:

方案 1:toolbarColorScheme + 隐藏导航栏背景

.toolbarBackground(.hidden, for: .navigationBar)
.toolbarColorScheme(.light, for: .navigationBar)

想法:既然背景图要透出来,就把导航栏背景隐藏;再通过 toolbarColorScheme(.light) 让标题和 status bar 走浅色 scheme,也就是深色文字。

结果toolbarColorScheme 只在导航栏背景可见时才被系统可靠尊重。背景隐藏后,这个 modifier 对 Large Title 基本失效,只能控制状态栏时间颜色。Large Title 仍然是系统默认色,浅色背景上看不见。

方案 2:显示系统 bar material

.toolbarBackground(.bar, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.toolbarColorScheme(.light, for: .navigationBar)

想法:按 Apple 官方建议,在导航栏背后加一层系统 material(毛玻璃),让系统处理 vibrancy 和前景可读性。

结果:Large Title 确实能看见了,但导航栏出现了一条不透明的毛玻璃带,把用户精心选的背景图顶部挡住了。这和“让背景图完整展示”的产品诉求冲突,用户不接受。

方案 3:UIKit bridge 通过 UIViewControllerRepresentable

struct NavigationBarAppearanceHost: UIViewControllerRepresentable {
    func makeUIViewController(...) -> Controller { ... }
}

final class Controller: UIViewController {
    func updateNavigationBarAppearance() {
        guard let navigationBar = navigationController?.navigationBar else { return }
        let appearance = UINavigationBarAppearance()
        appearance.configureWithTransparentBackground()
        appearance.largeTitleTextAttributes = [.foregroundColor: UIColor.black]
        navigationBar.scrollEdgeAppearance = appearance
    }
}

想法:SwiftUI 的 toolbar API 不够细,那就用 UIKit 的 UINavigationBarAppearance 直接改 scrollEdgeAppearance,并配置透明背景,只改 Large Title 颜色,不改折叠后的小标题。

结果:编译通过,但运行时 navigationController?.navigationBar 几乎总是 nil。原因是一个 UIViewControllerRepresentable 被 SwiftUI 塞进 .background{} 后,它所在的 VC 并不在 UINavigationController 的 view controller 栈里,而是被包在多层 hosting / 私有容器里,navigationController 属性没有正确设置。

方案 4:采样真实 Large Title label 的 frame

在方案 3 失效后,又尝试回到亮度采样,但只采样“实际 Large Title label 背后”那一小块区域:

// 遍历 UINavigationBar 子视图找到字号最大的 UILabel
let titleLabel = navigationBar.subviews
    .compactMap { $0 as? UILabel }
    .max { $0.font.pointSize < $1.font.pointSize }
let frame = titleLabel.convert(titleLabel.bounds, to: window)
let luminance = map.averageLuminance(in: frame)

结果:frame 回调时机和 SwiftUI 渲染周期不同步,经常拿到旧值;而且这仍然属于“自创采样”,不符合用户要求。

最终方案:Responder Chain + UIViewRepresentable

既然 UIViewControllerRepresentable 拿不到 navigationController,而 SwiftUI toolbar modifier 又无法做到“透明背景 + 指定 Large Title 颜色”,最后改用 UIViewRepresentable + UIKit responder chain 遍历

核心思路:

  1. UIViewRepresentable 创建一个零尺寸视图。
  2. 该视图加入窗口后,从 self.next 开始沿 UIKit responder chain 向上遍历。
  3. 遇到 UIViewController 时检查它的 navigationController,从而拿到真实的 UINavigationBar
  4. 只设置 scrollEdgeAppearance:透明背景 + 深色 Large Title 文字。
  5. DispatchQueue.main.async 延迟执行,确保在 SwiftUI 的当前渲染 pass 完成后再写入 appearance,避免被 SwiftUI 的 toolbar 偏好覆盖。
private struct NavigationBarLargeTitleFixer: UIViewRepresentable {
    let isEnabled: Bool

    func makeUIView(context: Context) -> FixerView { FixerView() }
    func updateUIView(_ uiView: FixerView, context: Context) {
        uiView.isEnabled = isEnabled
        uiView.scheduleApply()
    }

    final class FixerView: UIView {
        var isEnabled = false
        private var savedScrollEdgeAppearance: UINavigationBarAppearance??
        private weak var configuredBar: UINavigationBar?

        override func didMoveToWindow() {
            super.didMoveToWindow()
            scheduleApply()
        }

        override func willMove(toWindow newWindow: UIWindow?) {
            if newWindow == nil { restore() }
            super.willMove(toWindow: newWindow)
        }

        func scheduleApply() {
            // 延迟到 SwiftUI render pass 之后
            DispatchQueue.main.async { [weak self] in self?.apply() }
        }

        private func apply() {
            guard isEnabled else { restore(); return }
            guard let navBar = findNavigationBar() else { return }

            if configuredBar !== navBar {
                restore()
                configuredBar = navBar
                savedScrollEdgeAppearance = .some(navBar.scrollEdgeAppearance)
            }

            let appearance = UINavigationBarAppearance()
            appearance.configureWithTransparentBackground()
            appearance.largeTitleTextAttributes = [
                .foregroundColor: UIColor.label.resolvedColor(
                    with: UITraitCollection(userInterfaceStyle: .light)
                )
            ]
            navBar.scrollEdgeAppearance = appearance
        }

        private func restore() {
            guard let navBar = configuredBar else { return }
            if let saved = savedScrollEdgeAppearance {
                navBar.scrollEdgeAppearance = saved
            }
            configuredBar = nil
            savedScrollEdgeAppearance = nil
        }

        private func findNavigationBar() -> UINavigationBar? {
            var responder: UIResponder? = next
            while let r = responder {
                if let vc = r as? UIViewController,
                   let navVC = vc.navigationController {
                    return navVC.navigationBar
                }
                responder = r.next
            }
            return nil
        }
    }
}

Modifier 入口保持原有调用不变:

extension View {
    func adaptiveNavigationBarForeground(
        backgroundStyle: AppState.BackgroundStyle,
        image: UIImage?,
        blurRadius: Double,
        dimOpacity: Double
    ) -> some View {
        modifier(AdaptiveNavigationBarForegroundModifier(backgroundStyle: backgroundStyle))
    }
}

调用点无需改动:

.navigationTitle(activeFocusTitle)
.navigationBarTitleDisplayMode(.large)
.adaptiveNavigationBarForeground(
    backgroundStyle: appState.backgroundStyle,
    image: appState.customBackgroundImage,
    blurRadius: appState.customBackgroundBlur,
    dimOpacity: appState.customBackgroundDim
)

为什么这个方案能work

问题点之前方案的问题最终方案的解决方式
拿不到 UINavigationBarUIViewControllerRepresentable 嵌套太深,navigationController 为 nilUIViewRepresentable,沿 responder chain 向上找,稳定拿到 UINavigationController
SwiftUI 覆盖 UIKit 设置同步设置 appearance 后,SwiftUI render pass 结束又把它改回去DispatchQueue.main.async 延迟到 render pass 之后执行
背景被遮挡.toolbarBackground(.bar) 会加不透明 material只改 scrollEdgeAppearance,并 configureWithTransparentBackground(),背景图完整透出
影响折叠后的小标题改了 standardAppearance / compactAppearance 全局只改 scrollEdgeAppearance,折叠后的 inline title 仍走系统默认
非自定义背景时残留没有恢复机制willMove(toWindow: nil) 时恢复原始 appearance

关键取舍

  • Large Title 颜色固定为深色:没有继续走“按背景图动态采样黑白”的路线,因为公开 API 里没有稳定的原生入口;固定深色字在浅色背景图上可读性最好,而深色背景图通常会被用户手动调暗(dim),此时深色字依然可读。
  • 只处理展开态 Large Title:折叠后导航栏回到系统默认行为,不干预 inline title。
  • 透明导航栏背景:产品要求背景图完整展示,所以不引入 material 层。

经验教训

  1. SwiftUI 的 toolbar modifier 和 UIKit navigation appearance 是两套偏好系统,同步混用很容易互相覆盖。如果必须用 UIKit 兜底,最好延迟到 SwiftUI render pass 之后。
  2. UIViewControllerRepresentable 不是拿到当前 navigation controller 的可靠方式,尤其被放进 .background{} 时。Responder chain 对 UIKit 视图来说更稳定。
  3. 能用 SwiftUI 原生 modifier 解决的问题不要绕过;原生 API 覆盖不到时,要明确告诉用户这是能力边界,而不是用“看起来原生”的自定义采样伪装。
  4. 恢复机制很重要:修改全局 UINavigationBarAppearance 会跨越页面生命周期,视图消失时要把原 appearance 还回去,否则下一个页面会被污染。

参考