Grow on iOS 26:UIKit + SwiftUI 混合架构下的 Liquid Glass 适配实战

发表于

Grow 是一款在 173 个国家和地区获得 App Store 编辑推荐、拥有超过 18 万五星评价的健康管理应用。在适配 iOS 26 的 Liquid Glass 设计语言时,团队遇到了不少挑战:如何在 UIKit + SwiftUI 混合架构下实现原生的 morph 效果?如何精确控制 Scroll Edge Effect?如何处理自定义导航栏元素的动态尺寸?

我邀请了 Grow 的开发者之一 Shuhari,分享团队在这次适配过程中的实战经验。文章涵盖 Sheet、Navigation、Popover 等场景的改造方案,深入探讨 UIBarButtonItem 尺寸计算、CABackdropLayer 副作用处理等底层细节,还展示了如何利用 Core Text 创造“玻璃文字”效果。所有核心概念都配有完整的 Demo 工程


Grow 作为一款从 2021 年就开始演进的健康类产品,我们力求通过风格化设计将枯燥的数据面板融入一些有趣和细腻的元素。而这些元素在代码实现上自然带来了更高的要求——复杂的 UI 和交互成为了我们需要重点解决的问题。从项目初期的 Storyboard/XIB 到以 UIKit 为基础、SwiftUI 为功能辅助的混合架构,Grow 始终在寻找设计理念与技术实现之间的平衡。

如上文所述,Grow 的代码结构是相对传统的、基于 UIKit 的设计。因此下文列出的适配经验主要针对 UIKit 或混合架构的应用,并不完全适用于纯 SwiftUI 架构的 app。

自 iOS 26 发布前夕,Grow 就已经开始根据传闻进行技术预研,并尝试与新的设计范式相结合。在 WWDC25 后,团队很快着手 Liquid Glass 的交互和设计细化,首先明确了两个基本的适配原则:

  • 保持旧版本 iOS 上 Grow 用户的使用体验不变;
  • 为升级至 iOS 26 的用户带来熟悉的交互,但耳目一新的视觉体验。

要达成这两项目标,需要先了解项目的现状。让我们从 presentation sheet 的改造开始:

Sheet 改造

Build a UIKit app with the new design 中,Apple 明确指出,要充分展现 Liquid Glass 的玻璃质感,需要移除所有自定义的背景内容。对于使用了 sheet(isPresented:onDismiss:content:)UISheetPresentationController 等 API 的应用来说,只需通过 #available(iOS 26.0, *) 等可用性检查来区分不同系统版本的背景配置即可。

Presentation Sheet on both iOS18 and iOS26

此外,要控制边缘模糊效果(Scroll Edge Effect),可以使用 ToolbarItem(placement: .bottomBar) 来激活效果,使用 .safeAreaInset(edge: .bottom) 来禁用效果。例如在页面底部需要保持内容清晰时,就可以通过后者来取消模糊。

Scroll Edge Effect

Grow 使用 UINavigationController 来实现导航系统,但早期版本并没有直接使用其中的 UINavigationBar,而是通过自定义 UIView 来承载复杂的 UI 元素,以模拟导航栏的功能。从支持 iOS 26 的新版本开始,我们将原生的 UINavigationBar 带了回来,希望通过 Apple 的最佳实践来统一设计语言并提高开发效率。

Navigation on Today

Grow 的五个 Tab 页面结构类似,都是在 UINavigationController 容器中展示导航栏,并在底部使用 UIScrollView 等滚动容器来呈现页面内容。因此只需配置 navigationItem 并使用 Auto Layout 来布局滚动容器即可:

Swift
private func setupNavigationItems() {
    navigationItem.largeTitleDisplayMode = .never
    navigationItem.leftBarButtonItem = ...
    navigationItem.rightBarButtonItem = ...
}

private func setupCollectionView() {
    collectionView.translatesAutoresizingMaskIntoConstraints = false
                    
    NSLayoutConstraint.activate([
        collectionView.topAnchor.constraint(equalTo: view.topAnchor),
        collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
        collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
        collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
    ])
}

使用 popoverPresentationController 实现 Morph Menu 效果

为了融入更多 Liquid Glass 元素,我们将“洞悉(Insights)”页面上原本以 sheet 呈现的日期选择组件调整为自定义的 popover menu 形式:

Insights

在 UIKit 中,我们在 UIViewController 中布局 UI 元素后,使用 popoverPresentationController 并将 navigationItem.rightBarButtonItem 绑定到 .sourceItem 上,即可实现此类效果:

Swift
 private func setupCalendarButton() {
    let rootView = InsightsCalendarDatePickerButton(viewModel: calendarViewModel)
    let hostingController = UIHostingController(rootView: rootView)
    hostingController.view.backgroundColor = .clear
    hostingController.sizingOptions = [.intrinsicContentSize]
    
    let barButtonItem = UIBarButtonItem(customView: hostingController.view)
    navigationItem.rightBarButtonItem = barButtonItem
    
    calendarBarButtonItem = barButtonItem
}

private func calendarButtonTapped() {
    let popoverVC = InsightsCalendarDatePickerViewController(
        currentDate: currentMonth,
        minimumDate: minimumDate
    ) { [weak self] updatedDate in
        self?.calendarViewModel.updateMonth(using: updatedDate)
    }

    popoverVC.modalPresentationStyle = .popover
    popoverVC.popoverPresentationController?.sourceItem = calendarBarButtonItem
    popoverVC.popoverPresentationController?.delegate = self

    present(popoverVC, animated: true)
}

由于最终选择的日期在 rightBarButtonItem 上显示的 UI 宽度可能不同,因此需要在构建 UIHostingController 时将 sizingOptions 设置为 [.intrinsicContentSize],使其遵循 root view 的内容尺寸。

如果你的应用需要在其他场景中以 SwiftUI 的形式实现类似效果,可以参考 Kavsoft 的这两个 demo:

Grow 没有采用这两种实现方式的原因有以下几点:

  1. 基于 UIKit 的导航栏通过 UIHostingController 桥接纯 SwiftUI 实现时,在数据协调和视图定位上会比较复杂。
  2. 导航栏空间有限,显示超出范围的子视图通常需要处理复杂的响应链问题。

而通过 UIPopoverPresentationControllerDelegate 可以优雅地解决上述两个问题:

Swift
func popoverPresentationControllerShouldDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) -> Bool {
    return true
}

func prepareForPopoverPresentation(_ popoverPresentationController: UIPopoverPresentationController) {
    if let presentedVC = popoverPresentationController.presentedViewController as? InsightsCalendarDatePickerViewController {
        presentedVC.view.layoutIfNeeded()
    }
}
  • popoverPresentationControllerShouldDismissPopover(_:) 解决了响应优先级问题:当 popover 呈现后,除了手动调用 dismiss(animated:) 外,点击画面上的任何位置都会优先关闭 popover。
  • prepareForPopoverPresentation(_:) 允许我们在 popover 最终呈现前对内容进行处理,例如内容尺寸计算、重置内部动画状态等。

UIKit + SwiftUI 环境下 UIBarButtonItem 的尺寸计算

Grow 在 Tab 的一些顶级页面的导航栏左侧设计了明确的页面标题,其中一部分标题还会通过 .contentTransition(.numericText()) 来实现简单的内容切换动画。以今天(Today)页面为例,当用户选择不同的日期时,需要显示不同长度的日期文字:

考虑到需要在 UIKit 的 navigationItem 上配置这个自定义视图,可能会遇到以下两个问题:

  1. 应该选用什么框架来支持这项功能以及切换动画?
  2. 如何在 navigationItem 中处理日期文字的动态宽度?

我们来逐一解决:

第一个问题比较简单。使用 .contentTransition(.numericText()) 最便捷的方式是通过 SwiftUI View 并注入 ObservableObject,在 UIKit 环境中调整当前选中的 Date 数据即可。

要处理第二个问题,首先将 UIHostingController 包装的 Text 视图通过 UIBarButtonItem(customView:) 构造,并将其设置为 navigationItem.leftBarButtonItem。同时移除 iOS 26 上的 Liquid Glass 效果:barButtonItem.hidesSharedBackground = true。此外,为了确保 SwiftUI 能正确计算内容尺寸并传递给 UIKit,需要将容器的 sizingOptions 设置为 intrinsicContentSize。它会在每次 Text 内容变化、view body 重新计算时自动更新。

除了 SwiftUI 的尺寸设定,还需要调整 UIKit 导航栏上的约束条件。navigationItem 上通过 custom view 配置的内容默认不会根据内容变化而重新计算尺寸(注意下方视频中的橙色背景)。

很明显,视频中的橙色背景没有跟随 intrinsicContentSize 调整,说明它被压缩了。我们只需要将内容抗压缩优先级调高即可:

Swift
hostingController.view.backgroundColor = .systemOrange
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
hostingController.view.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)

Liquid Glass 的效果很棒,但我想加以控制

当我们调用 pushViewController(_:animated:) 后,iOS 26 导航栏上默认的 morph transition 效果会产生副作用(橙色矩形过渡到圆形返回按钮的不自然动画):

要处理这个问题,关键在于不要配置 navigationItem 上的任何 bar button item 内容。既然 titleView 也能接收 UIView,我们可以尝试将 UIHostingController 放置在 navigationItem.titleView 上:

On navigationItem.titleView

第二步,我们需要移除抗压缩优先级,转而调整内容环抱优先级(Content Hugging Priority),将其在水平方向设置为 .fittingSizeLevel,表示这个视图”愿意被扩展和拉伸”:

Swift
hostingController.view.setContentHuggingPriority(.fittingSizeLevel, for: .horizontal)

On navigationItem.titleView with fittingSizeLevel

最后,我们只需在 SwiftUI 中为 Text 添加内容填充以及向左对齐的修饰符,即可模拟出类似 navigationItem.leftBarButtonItem 的样式。然后再调用 pushViewController(_:animated:),就不会再有 morph 效果的副作用了:

除了导航栏 push/pop 产生的 morph 效果,某些场景下我们还需要对滚动视图的 Scroll Edge Effect 进行条件控制。一个典型场景是:当页面顶部有大图、地图等视觉重点元素时,通常不希望它们被模糊处理。在新版本的 Grow 中,我们对挑战(Challenge)页面也进行了调整:

Challenge

在进入挑战详情页时,我们需要保证顶部图片清晰,然后在滚动过程中,经过大标题后再触发 Scroll Edge Effect 效果。实现上,使用 UICollectionReusableView 作为 supplementary header view,配合内容 section,是比较常规的方案。但问题在于:header 和 section 都属于 UICollectionView 的滚动元素,滚动时会触发 top/bottom 对应的 CABackdropLayer,导致 edge effect view 始终会被创建:

要规避此类问题,我们可以将 supplementary header view 独立为一个直接添加在 UICollectionView 上的视图,而不通过 UICollectionViewCompositionalLayout 进行布局。然后设定一个与 header view 相同高度的 contentInset.top。这样就无需计算滚动到多少 offset 才需要手动调整 UIScrollEdgeElementContainerInteraction 的隐藏和显示,只要 cell 进入导航栏的范围内,UIKit 就会自动处理 Scroll Edge Effect:

Liquid Glass 的 Shape

glassEffect(_:in:) 提供了一种让 View 应用 Liquid Glass 的便捷方式。在 Grow 中,通常只需调整 Glass 的样式参数即可满足绝大多数场景。我们注意到 API 的第二个参数接受 Shape 类型,这为进一步的扩展提供了可能。

不知道有没有朋友注意到,在支持 iOS 26 的第一个版本中,我们在 What’s New 部分为顶部的标题文字应用了 Liquid Glass 效果:

借助 Core Text 中的 CTFontCreatePathForGlyph 将字形转换为 CGPath,然后组合成一个 glyph shape 传递给 glassEffect(_:in:) 进行形状渲染,我们就可以获得一个不受纯粹几何形状限制、可自定义内容的通透”玻璃文字”材质。

Demo

为了便于理解和实践,我将上述内容整理成了一个 demo 工程:Insights。demo 围绕核心概念组织,每个模块都包含了完整的实现代码,可以直接查看效果,也可以将感兴趣的部分集成到自己的项目中。代码注释覆盖了关键实现细节,方便快速定位和理解。

需要说明的是,这个 demo 采用了较为实验性(experimental)的开发方式,因此在一些细节处理上可能不够严谨。如果在使用过程中发现问题或有改进建议,欢迎直接联系我一起讨论。

每周精选 Swift 与 SwiftUI 精华!