iOS 26 适配
从 iOS 26 开始,iOS 迎来了大更新,强制使用
SceneDelegate、新增的 Liquid Glass 以及其他问题,正好最近在做项目中 iOS 26 适配相关的工作,抽个时间,把遇到的适配问题整理出来,做个记录。
一、SceneDelegate
1.1 为什么要迁移
以前创建 Window、设置根控制器、处理界面前后台切换,都可以写在 AppDelegate 中。采用 Scene 生命周期之后,这些事情由 SceneDelegate 接收。
Apple 已经明确:从 iOS 27 起,使用最新 SDK 构建的 UIKit 应用必须采用 Scene 生命周期,否则无法启动。 这个要求针对使用 iOS 27 SDK 或更新 SDK 构建,并运行在 iOS 27 或更新系统上的应用。
iOS 26 也把 AppDelegate 中一批界面生命周期和链接处理方法标记为弃用。这里的弃用不等于方法被删除,但采用 Scene 后,界面回调要从 Scene 的入口处理。
1.2 添加 Scene 配置
项目只支持单个窗口的,设置不支持多场景就可以了:UIApplicationSupportsMultipleScenes 设置为 false。
先创建 SceneDelegate.swift,然后在 Info.plist 中添加:
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
1.3 在 SceneDelegate 中创建 Window
接下来打开 SceneDelegate.swift,添加 Window 和连接回调:
import UIKit
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else {
return
}
let rootController = UIViewController()
rootController.view.backgroundColor = .systemBackground
let window = UIWindow(windowScene: windowScene)
window.backgroundColor = .systemBackground
window.rootViewController = rootController
self.window = window
window.makeKeyAndVisible()
}
}
-
创建 Window 时使用
UIWindow(windowScene:),让它和当前连接的 Scene 关联。 -
窗口保存在
SceneDelegate中,最后调用makeKeyAndVisible()显示。
1.4 迁移界面回调
Window 创建好之后,再检查原来的界面生命周期方法:
原来的 AppDelegate 方法 | Scene 对应入口 |
|---|---|
applicationDidBecomeActive | sceneDidBecomeActive |
applicationWillResignActive | sceneWillResignActive |
applicationWillEnterForeground | sceneWillEnterForeground |
applicationDidEnterBackground | sceneDidEnterBackground |
把与当前界面有关的处理放到对应的 Scene 方法中。应用级初始化、远程通知注册等,仍在 AppDelegate 中处理。
二、启动页
2.1 配置系统 Launch Screen
上传使用 iOS 27 SDK 或更新 SDK 构建的应用时,最终 App 的
Info.plist必须包含有效的启动页配置。
至少使用 UILaunchStoryboardName、UILaunchStoryboards、UILaunchScreen、UILaunchScreens 中的一种。
缺少配置时,App Store Connect 会以
ITMS-90870拒绝上传。
三、Liquid Glass
3.1 先看系统组件
iOS 26 的 Liquid Glass,也就是液态玻璃,会影响导航栏、TabBar、工具栏和系统按钮。
使用 iOS 26 或更新 SDK 构建,并在支持新设计的系统上运行时,系统标准组件默认采用新的设计;仍有效的旧设计兼容配置会影响实际外观。
所以换了 SDK 之后,先看一下系统栏和按钮。
-
原来只有一个图标的地方,可能多了背景;
-
原来自己画了背景的控件,也可能和系统背景叠在一起。
-
如果继续使用系统设计,就调整颜色、文字和布局。
-
如果要保留自己的外观,再处理背景、按钮分组,或者使用自定义组件。
3.2 UIDesignRequiresCompatibility
暂时需要保留旧设计,可以在 Info.plist 中添加:
<key>UIDesignRequiresCompatibility</key>
<true/>
这个字段用于启用旧设计兼容模式。不过,使用 iOS 27 SDK 构建时,系统会忽略它。
| 构建时使用的 SDK | 在 iOS 26、iOS 27 上运行,配置为 true |
|---|---|
| Xcode 26 的 SDK | 仍遵守旧设计兼容配置 |
| Xcode 27 的 SDK | 忽略该配置 |
设置深色模式、修改栏的底色,只是在调整外观,也不能当成关闭液态玻璃的开关。后面再分别看 TabBar、导航栏和按钮如何处理。
四、push、pop 和侧滑返回时漏底
iOS 26 更新了系统导航转场,转场过程中出现的圆角裁切,会让页面下面的背景露出来。
Apple 在 WWDC25 介绍了新的标准导航滑动转场。
可以这样理解:
-
页面正常显示时铺满屏幕,底层是什么颜色看不出来;push、pop 或侧滑返回时,页面移动并出现圆角,被裁掉的边角就会露出底层。
-
如果页面是深色,底层却是白色,就会看到四角漏白或短暂闪白。
不过,露出的不一定直接是
UIWindow,也可能是UINavigationController.view或其他父容器。因此,适配时需要检查这几层:
// 创建窗口时
window.backgroundColor = pageBackgroundColor
// 导航控制器的 viewDidLoad 中
view.backgroundColor = pageBackgroundColor
// 页面控制器的 viewDidLoad 中
view.backgroundColor = pageBackgroundColor
这里的 pageBackgroundColor 指项目中统一的背景底色。对于底色统一的页面,把窗口、导航容器和页面的背景保持一致,就能处理这种圆角转场漏底。
五、TabBar 适配
5.1 使用原生 UITabBarController
准备采用系统的新设计,可以继续使用 UITabBarController。先把颜色、文字和滚动时的外观配好。
下面这段放在 TabBar 控制器的 viewDidLoad 中:
let appearance = UITabBarAppearance()
appearance.configureWithDefaultBackground()
appearance.backgroundColor = .systemBackground
appearance.shadowColor = .clear
let layouts = [
appearance.stackedLayoutAppearance,
appearance.inlineLayoutAppearance,
appearance.compactInlineLayoutAppearance
]
for layout in layouts {
layout.normal.titleTextAttributes = [
.font: UIFont.systemFont(ofSize: 10),
.foregroundColor: UIColor.secondaryLabel
]
layout.selected.titleTextAttributes = [
.font: UIFont.systemFont(ofSize: 10),
.foregroundColor: UIColor.systemBlue
]
layout.normal.iconColor = .secondaryLabel
layout.selected.iconColor = .systemBlue
}
if #available(iOS 27.0, *) {
appearance.overrideUserInterfaceStyle = .dark
}
tabBar.standardAppearance = appearance
if #available(iOS 15.0, *) {
tabBar.scrollEdgeAppearance = appearance
}
三个 Layout Appearance 分别处理上下排列、横向排列和紧凑排列。字体、文字颜色和图标颜色按界面要求替换。
overrideUserInterfaceStyle 是 iOS 27 提供的栏外观属性。上面用深色举例,需要跟随系统时可以不设置;它调整深浅色表现,并不是关闭液态玻璃。
UIBarAppearance.overrideUserInterfaceStyle
scrollEdgeAppearance 也要一起检查。没有单独设置时,系统会根据普通外观派生滚动边缘外观,背景可能变透明。希望两个状态一致,就像上面这样赋同一份配置。
5.2 使用 CYLTabBarController
如果不准备显示液态玻璃,需要保留平坦的 TabBar,可以考虑 CYLTabBarController。
这里要选对模式。库的普通模式仍使用系统 TabBar,安装之后并不会自动消除液态玻璃。需要平坦样式时,选择
CYLFlatDesignTabBar组件。
在继承 CYLTabBarController 的控制器中设置:
- (void)viewDidLoad {
self.tabBarStyleType = CYLTabBarStyleTypeFlatDesign;
[super viewDidLoad];
}
注意顺序,样式必须在
[super viewDidLoad]之前设置,父类才能按指定模式创建组件。
FlatDesign 模式会隐藏系统 TabBar,使用库自己的组件绘制,并维护对应安全区。
六、导航栏返回手势处理
6.1 使用系统全屏返回手势
iOS 26 之前,系统只支持扣边返回,想要支持全屏侧滑返回,需要自己实现全屏侧滑返回手势处理。iOS 26 开始提供了 interactiveContentPopGestureRecognizer,可以从内容区域触发返回。
interactiveContentPopGestureRecognizer
与使用 interactivePopGestureRecognizer 一样,在自定义导航控制器中实现:
class NavigationController: UINavigationController {
override func viewDidLoad() {
super.viewDidLoad()
// 从 iOS 26 开始,Apple 支持全屏右滑返回手势。
if #available(iOS 26.0, *) {
interactiveContentPopGestureRecognizer?.delegate = self
} else {
interactivePopGestureRecognizer?.delegate = self
}
}
}
// MARK: - UIGestureRecognizerDelegate
extension NavigationController: UIGestureRecognizerDelegate {
/// 根控制器不允许触发返回手势,其余场景保留系统行为。
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if #available(iOS 26.0, *) {
if gestureRecognizer == interactiveContentPopGestureRecognizer {
if viewControllers.count < 2 || visibleViewController === viewControllers.first {
return false
}
}
} else if gestureRecognizer == interactivePopGestureRecognizer {
if viewControllers.count < 2 || visibleViewController === viewControllers.first {
return false
}
}
return true
}
}
在某个页面需要禁止返回时,把对应手势禁用;离开后,下一个页面要按自己的规则重新设置。可以写一个专门处理导航栏返回手势的基类:
class PopGestureController: UIViewController {
/// 是否启用抠边右滑返回手势
var isSwipeBackEnabled: Bool {
return true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if isSwipeBackEnabled == false {
popGesture(true)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if isSwipeBackEnabled == false {
popGesture(false)
}
}
}
extension PopGestureController {
/// 设置返回手势
@objc func popGesture(_ isEnabled: Bool) {
if #available(iOS 26.0, *) {
// 全屏返回手势
if let interactiveContentPopGestureRecognizer = navigationController?.interactiveContentPopGestureRecognizer {
//这里对添加到右滑视图上的所有手势禁用或者启用
let gestureRecognizers = interactiveContentPopGestureRecognizer.view?.gestureRecognizers ?? []
for popGesture in gestureRecognizers {
popGesture.isEnabled = isEnabled
}
//若开启全屏右滑,不能再使用下面方法,请对数组进行处理
interactiveContentPopGestureRecognizer.isEnabled = isEnabled
}
} else {
// 侧滑返回手势
if let interactivePopGestureRecognizer = navigationController?.interactivePopGestureRecognizer {
//这里对添加到右滑视图上的所有手势禁用或者启用
let gestureRecognizers = interactivePopGestureRecognizer.view?.gestureRecognizers ?? []
for popGesture in gestureRecognizers {
popGesture.isEnabled = isEnabled
}
//若开启全屏右滑,不能再使用下面方法,请对数组进行处理
interactivePopGestureRecognizer.isEnabled = isEnabled
}
}
}
}
-
在
viewWillDisappear方法中,手势开启, -
在
viewDidAppear方法中,可以设置某个页面禁止手势。
子类只需要继承 PopGestureController,通过重写 isSwipeBackEnabled 决定页面手势是否禁止。
6.2 使用 JXSegmentedView、JXPagingView 与系统返回手势的冲突
相信绝大多数 iOS 开发都会使用 JXSegmentedView、JXPagingView 这两个库,使用这两个库必然会遇到与系统返回手势的冲突的问题。
iOS 26 的全屏返回手势处理比较简单,只需要开启就好,侧滑到最左边的页面时,再侧滑会触发导航栏侧滑返回手势。
主要是 iOS 26 以下扣边返回手势处理,需要保证扣边返回手势优先于 UIScrollView 的滚动手势,可以在控制器中提供 configureScrollViewPopGestureConflictResolution(for:) 方法:
/// 配置使用 JXSegmentedView 和 JXPagingView/Paging 时与导航控制器返回手势的冲突解决方案
@objc func configureScrollViewPopGestureConflictResolution(for scrollView: UIScrollView) {
// iOS 26.0+ 系统:处理全屏返回手势冲突
// 确保全屏返回手势处于启用状态
if isSwipeBackEnabled {
self.navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = true
}
} else {
// iOS 26.0 以下系统:处理传统边缘返回手势冲突
if isSwipeBackEnabled {
// 同样的优先级设置,确保边缘返回手势优先于 ScrollView 滚动
if let navigationController = self.navigationController,
let interactivePopGestureRecognizer = navigationController.interactivePopGestureRecognizer {
scrollView.panGestureRecognizer.require(toFail: interactivePopGestureRecognizer)
}
// 确保边缘返回手势处于启用状态
self.navigationController?.interactivePopGestureRecognizer?.isEnabled = true
}
}
}
在使用 JXSegmentedView、JXPagingView 遇到与系统返回手势冲突的地方调用 configureScrollViewPopGestureConflictResolution(for:) 将控制分页显示的 UIScrollView 传进去就可以解决这个问题。
6.3 iOS 26 使用全屏返回手势遇到 JXSegmentedView、JXPagingView 需要扣边返回的处理
在 iOS 26 使用全屏返回手势时并使用 JXSegmentedView、JXPagingView 时,当列表容器显示的不是第一页,但用户需要通过扣边返回上一页,我们需要实现 JXSegmentedView 的 scrollViewClass(in:) 和 JXPagingView 的 scrollViewClassInListContainerView(in:) 方法将自定义的 UIScrollView 或者 UICollectionView 进行返回。
以 UIScrollView 为例:
class SegmentedListContainerScrollView: UIScrollView {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureClass = NSClassFromString("UIScrollViewPanGestureRecognizer"), gestureRecognizer.isMember(of: panGestureClass) {
guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer,
let window = self.window
else {
return true
}
let translation = panGesture.translation(in: window)
let location = panGesture.location(in: window)
//判断是否是扣边右滑
let isEdgeSwipe = translation.x > 0 && location.x < 30
if isEdgeSwipe {
// 让 ScrollView 手势失败,触发返回手势
return false
}
//只有在最左边且是右滑时,才让返回手势优先
let shouldTriggerPopGesture = translation.x > 0 && self.contentOffset.x <= 0
if shouldTriggerPopGesture {
// 此时让 ScrollView 手势失败,系统会自动触发返回手势
return false
}
}
// 其他情况:正常滚动
return true
}
}
class PagingListContainerScrollView: UIScrollView {
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if let panGestureClass = NSClassFromString("UIScrollViewPanGestureRecognizer"), gestureRecognizer.isMember(of: panGestureClass) {
guard let panGesture = gestureRecognizer as? UIPanGestureRecognizer,
let view = panGesture.view,
let window = self.window
else {
return true
}
let velocityX = panGesture.velocity(in: view).x
if velocityX > 0 {
//当前在第一个页面,且往左滑动,就放弃该手势响应,让外层接收,达到多个PagingView左右切换效果
if contentOffset.x == 0 {
return false
}
} else if velocityX < 0 {
//当前在最后一个页面,且往右滑动,就放弃该手势响应,让外层接收,达到多个PagingView左右切换效果
if contentOffset.x + bounds.size.width == contentSize.width {
return false
}
}
let translation = panGesture.translation(in: window)
let location = panGesture.location(in: window)
//判断是否是扣边右滑
let isEdgeSwipe = translation.x > 0 && location.x < 30
if isEdgeSwipe {
// 放弃该手势响应,让外层接收,达到触发返回手势
return false
}
//只有在最左边且是右滑时,才让返回手势优先
let shouldTriggerPopGesture = translation.x > 0 && self.contentOffset.x <= 0
if shouldTriggerPopGesture {
// 放弃该手势响应,让外层接收,达到触发返回手势
return false
}
}
return true
}
}
在需要处理手势的页面遵守 JXSegmentedListContainerViewDataSource、JXPagingListContainerViewDataSource 并实现 scrollViewClass(in:)、scrollViewClassInListContainerView(in:) 方法:
func scrollViewClass(in listContainerView: JXSegmentedListContainerView) -> AnyClass {
return SegmentedListContainerScrollView.self
}
func scrollViewClassInListContainerView(in pagingView: JXPagingView) -> AnyClass? {
return PagingListContainerScrollView.self
}
七、导航栏适配
7.1 使用系统导航栏
先来看导航控制器自带的导航栏。需要明确的底色时,可以创建 UINavigationBarAppearance:
let appearance = UINavigationBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = .systemBackground
appearance.shadowColor = .clear
appearance.titleTextAttributes = [
.font: UIFont.systemFont(ofSize: 18, weight: .medium),
.foregroundColor: UIColor.label
]
if #available(iOS 27.0, *) {
appearance.overrideUserInterfaceStyle = .dark
}
// 放在 UINavigationController 子类中
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
if #available(iOS 15.0, *) {
navigationBar.compactScrollEdgeAppearance = appearance
}
四个属性分别覆盖普通、滚动边缘和紧凑状态。滚动之后颜色突然不同,或者横屏时样式改变,先检查有没有漏配。
这里只设置具体的导航栏实例。如果只想改变某一类页面,不要一开始就用
UINavigationBar.appearance()全局修改,系统页面和第三方页面也可能受到影响。
页面的标题和按钮,仍设置到控制器的 navigationItem:
navigationItem.title = title
navigationItem.rightBarButtonItem = item
7.2 在页面里放一个导航栏
如果需要导航栏跟着页面移动,或者单独控制背景,可以自己封装一个 NavigationView 放在每个页面上。
如果不需要使用以前设置系统导航栏的方式去编写代码,完全可以自己自定义。
导航栏视图上的标题、左右按钮需要像使用系统导航栏的 UINavigationItem 一样通过 UIBarButtonItem 设置的,可以直接在 NavigationView 中添加 UINavigationBar 和 UINavigationItem,通过设置 navigationBar.setItems([navigationItem], animated: false),就可以达到与使用系统导航栏的编写方式进行设置,可以减少迁移成本。
在 NavigationView 中使用 UINavigationBar + UINavigationItem 的方式,例子如下:
在 NavigationView 中:
class NavigationView: UIView {
/// 导航栏背景视图。
let backgroundView = BackgroundView()
/// 页面自己的系统导航栏。
let navigationBar = UINavigationBar()
/// 仅由当前页面导航栏管理的 Item,不能与导航控制器共享栏复用同一个实例。
let navigationItem = UINavigationItem()
/// 导航栏标题,直接交由 `UINavigationItem` 显示。
var title: String? {
get { navigationItem.title }
set { navigationItem.title = newValue }
}
/// 创建页面导航视图。
override init(frame: CGRect) {
super.init(frame: frame)
setupUI()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupUI()
}
/// 组装背景、导航栏及默认导航 Item。
func setupUI() {
// 添加背景视图与导航栏
addSubview(backgroundView)
addSubview(navigationBar)
// 给导航栏添加导航栏项
navigationBar.setItems([navigationItem], animated: false)
}
override func layoutSubviews() {
super.layoutSubviews()
backgroundView.frame = bounds
// kTopSafeArea:手机屏幕顶部安全区域的高度
// kNavigationBarHeight:导航栏的高度(44),不是整个导航栏视图的高度
navigationBar.frame = CGRect(x: 0, y: kTopSafeArea, width: bounds.size.width, height: kNavigationBarHeight)
}
}
extension NavigationView {
/// 页面导航栏的背景视图。
class BackgroundView: UIView {
// ......
}
}
在 NavigationController 中:
/// 项目统一的导航控制器,负责在共享导航栏、页面导航栏和隐藏导航栏之间切换。
class NavigationController: UINavigationController {
/// 它用于区分:
/// - NavigationController 自动添加的返回按钮。
/// - 业务页面自己设置的左侧按钮。
private final class BackItem: UIBarButtonItem {}
// MARK: - 生命周期
override func viewDidLoad() {
super.viewDidLoad()
// 页面转场时会裁成圆角,导航容器需要与页面同色,避免露出系统默认白底。
view.backgroundColor = pageBackground
delegate = self
// 从 iOS 26 开始,Apple 支持全屏右滑返回手势。
if #available(iOS 26.0, *) {
interactiveContentPopGestureRecognizer?.delegate = self
} else {
interactivePopGestureRecognizer?.delegate = self
}
}
// MARK: - 页面跳转
/// 根据目标页面的导航栏模式配置底部栏和返回按钮,再执行 push。
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
let isRootViewController = viewControllers.isEmpty
if isRootViewController {
viewController.hidesBottomBarWhenPushed = false
} else {
configureBackButton(for: viewController, previous: viewControllers.last)
}
super.pushViewController(viewController, animated: animated)
}
/// 按新栈顺序配置返回按钮和 TabBar,覆盖未经 push 首次入栈的页面。
/// - Parameters:
/// - viewControllers: 替换后的完整控制器栈。
/// - animated: 是否执行系统转场动画。
override func setViewControllers(_ viewControllers: [UIViewController], animated: Bool) {
for (index, controller) in viewControllers.enumerated() {
let previous = index > 0 ? viewControllers[index - 1] : nil
controller.hidesBottomBarWhenPushed = previous != nil
configureBackButton(for: controller, previous: previous)
}
super.setViewControllers(viewControllers, animated: animated)
}
/// 兼容现有的控制器栈替换逻辑,为指定页面更新返回按钮策略。
/// - Parameter viewController: 需要配置返回按钮的控制器。
func setLeftReturnButton(_ viewController: UIViewController) {
let previous: UIViewController?
if let index = viewControllers.firstIndex(where: { $0 === viewController }) {
previous = index > 0 ? viewControllers[index - 1] : nil
} else {
previous = viewControllers.last
}
configureBackButton(for: viewController, previous: previous)
}
/// 根据页面导航栏模式配置统一样式的自定义返回按钮。
/// - Parameters:
/// - viewController: 需要配置的页面。
/// - previous: 实际前一页面,根页面传 nil。
private func configureBackButton(for viewController: UIViewController, previous: UIViewController?) {
let pageNavigationItem = (viewController as? BaseViewController)?.navigationBarItem
guard previous != nil else {
// 一个原本位于栈中间的页面,通过 setViewControllers 变成根页面后,需要删除之前自动生成的返回按钮:
// 如果没有 BackItem,直接清空 leftBarButtonItem 可能误删关闭按钮、取消按钮等业务按钮。
removeAutomaticBackButton(from: viewController.navigationItem)
pageNavigationItem.map(removeAutomaticBackButton(from:))
return
}
switch navigationBarMode(for: viewController) {
case .navigationController:
// 共享导航栏与页面导航栏都使用同一个自定义按钮,确保返回图像和点击区域一致。
pageNavigationItem.map(removeAutomaticBackButton(from:))
installAutomaticBackButton(on: viewController.navigationItem)
case .viewController:
removeAutomaticBackButton(from: viewController.navigationItem)
if let pageNavigationItem {
installAutomaticBackButton(on: pageNavigationItem)
}
case .hidden:
removeAutomaticBackButton(from: viewController.navigationItem)
pageNavigationItem.map(removeAutomaticBackButton(from:))
}
}
/// 在目标导航 Item 没有业务左侧按钮时,安装导航控制器管理的返回按钮。
/// - Parameter navigationItem: 承载返回按钮的导航 Item。
private func installAutomaticBackButton(on navigationItem: UINavigationItem) {
guard navigationItem.leftBarButtonItem == nil else { return }
navigationItem.leftBarButtonItem = makeBackButtonItem()
}
/// 仅移除导航控制器自动安装的返回按钮,避免影响业务页面自定义按钮。
/// - Parameter navigationItem: 需要检查的导航 Item。
private func removeAutomaticBackButton(from navigationItem: UINavigationItem) {
guard navigationItem.leftBarButtonItem is BackItem else { return }
navigationItem.leftBarButtonItem = nil
}
/// 创建两种导航栏模式共用的返回按钮,统一图像、布局和点击区域。
/// - Returns: 带有项目返回图像的导航按钮 Item。
private func makeBackButtonItem() -> BackItem {
BackItem(
image: UIImage(named: "back_item_icon"),
style: .plain,
target: self,
action: #selector(popToParent)
)
}
/// 获取页面声明的导航栏模式;普通 UIKit 控制器默认使用共享导航栏。
/// - Parameter viewController: 需要判断的控制器。
/// - Returns: 页面导航栏模式。
private func navigationBarMode(for viewController: UIViewController) -> NavigationBarMode {
(viewController as? NavigationBarModeProviding)?.navigationBarMode ?? .navigationController
}
// MARK: - Action
/// 返回上一个页面。
@objc private func popToParent() {
popViewController(animated: true)
}
}
// MARK: - UINavigationControllerDelegate
extension NavigationController: UINavigationControllerDelegate {
/// 在页面显示前统一切换共享导航栏,避免各页面在生命周期中互相覆盖状态。
func navigationController(
_ navigationController: UINavigationController,
willShow viewController: UIViewController,
animated: Bool
) {
var mode = navigationBarMode(for: viewController)
var isHidden = mode != .navigationController
setNavigationBarHidden(isHidden, animated: animated)
// 取消侧滑时恢复来源页的显隐,动画交由当前转场协调。
transitionCoordinator?.notifyWhenInteractionChanges { [weak self] context in
guard let `self` = self,
context.isCancelled,
let source = context.viewController(forKey: .from)
else {
return
}
mode = navigationBarMode(for: source)
isHidden = mode != .navigationController
self.setNavigationBarHidden(isHidden, animated: animated)
}
}
/// 转场结束后按实际可见页校准状态,包括取消返回后的来源页。
func navigationController(
_ navigationController: UINavigationController,
didShow viewController: UIViewController,
animated: Bool
) {
let mode = navigationBarMode(for: viewController)
let isHidden = mode != .navigationController
setNavigationBarHidden(isHidden, animated: false)
}
}
// MARK: - UIGestureRecognizerDelegate
extension NavigationController: UIGestureRecognizerDelegate {
/// 根控制器不允许触发返回手势,其余场景保留系统行为。
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if #available(iOS 26.0, *) {
if gestureRecognizer == interactiveContentPopGestureRecognizer {
if viewControllers.count < 2 || visibleViewController === viewControllers.first {
return false
}
}
} else if gestureRecognizer == interactivePopGestureRecognizer {
if viewControllers.count < 2 || visibleViewController === viewControllers.first {
return false
}
}
return true
}
}
在 BaseViewController 中:
/// 页面使用导航栏的方式。
enum NavigationBarMode: Equatable, Sendable {
/// 使用 `UINavigationController` 共享的系统导航栏。
///
/// 适合需要系统 Item 转场、Large Title 联动和系统栈返回按钮的页面。
case navigationController
/// 使用页面自己持有的 `NavigationView`。
///
/// 页面导航栏会跟随页面一起参与转场,适合项目现有的自定义导航栏样式。
case viewController
/// 页面不显示任何导航栏。
case hidden
}
/// 向 `YHNavigationController` 提供页面导航栏模式。
@MainActor
protocol NavigationBarModeProviding: AnyObject {
/// 当前页面使用导航栏的方式。
var navigationBarMode: NavigationBarMode { get }
}
class BaseViewController: PopGestureController, NavigationBarModeProviding {
/// 页面默认使用随页面参与转场的自定义导航栏。
var navigationBarMode: NavigationBarMode { .viewController }
/// 基类统一管理的页面导航视图。
private(set) lazy var navigationView = NavigationView()
/// 当前模式对应的导航 Item,业务页面统一通过此属性配置标题和左右按钮。
var navigationBarItem: UINavigationItem {
if navigationBarMode == .viewController {
return navigationView.navigationItem
}
return navigationItem
}
/// 页面标题变化时同步到页面级导航栏。
override var title: String? {
didSet {
if navigationBarMode == .viewController {
navigationView.navigationItem.title = title
}
}
}
// MARK: - 初始化
override func viewDidLoad() {
super.viewDidLoad()
addCustomNavigationBarViewIfNeeded()
}
/// 在页面模式下安装随控制器一起参与转场的导航栏。
func addCustomNavigationBarViewIfNeeded() {
guard case .viewController = navigationBarMode else { return }
// 添加自定义导航栏视图
view.addSubview(navigationView)
navigationView.snp.makeConstraints { make in
make.top.left.right.equalToSuperview()
make.height.equalTo(kNavigationViewHeight)
}
// 让自定义导航栏视图置于最上层
view.bringSubviewToFront(navigationView)
// 系统导航栏隐藏后,额外补足导航栏内容区域的安全距离。
additionalSafeAreaInsets.top += max(0, kNavigationBarHeight)
}
/// 在转场结束后执行刷新动作。
///
/// 交互式侧滑返回被取消时,`viewWillAppear` 会在转场尚未结束时再次触发,
/// 此时刷新列表会让 `UITableView` / `UICollectionView` 在几何未定型的状态下重算布局,
/// 出现内容错乱,因此列表刷新应当延后到转场结束之后执行。
/// - Parameter action: 需要在转场结束后执行的刷新动作。
func performAfterTransition(_ action: @escaping () -> Void) {
guard let coordinator = transitionCoordinator else {
action()
return
}
coordinator.animate(alongsideTransition: nil) { _ in
action()
}
}
}
7.3 高度变化
通过真机测试,如果在 iOS 26 上使用 Liquid Glass,导航栏的高度是
54,否则是44,所以定义导航栏高度代码如下:
/// 导航栏高度
var kNavigationBarHeight: CGFloat {
if #available(iOS 26.0, *) {
return 54
} else {
return 44
}
}
/// 导航栏视图高度
var kNavigationViewHeight: CGFloat {
return kTopSafeArea + kNavigationBarHeight
}
八、UIBarButtonItem
8.1 按钮背景和分组
iOS 26 之后,导航栏上的图标可能多出背景,相邻按钮也可能共用一块背景。
先看两个属性:
if #available(iOS 26.0, *) {
item.hidesSharedBackground = true
item.sharesBackground = false
}
-
hidesSharedBackground控制是否隐藏系统给 Item 绘制的背景,默认是false。 -
sharesBackground控制是否和相邻 Item 共用背景,默认是true。
hidesSharedBackground、sharesBackground
上面的写法适合不需要系统背景的按钮。如果想保留背景,但按钮分别显示,可以这样写:
if #available(iOS 26.0, *) {
item.hidesSharedBackground = false
item.sharesBackground = false
}
只把
sharesBackground改为false,不会把背景隐藏。多个 Item 显式放在同一个UIBarButtonItemGroup中时,这两个属性会被忽略。
8.2 UINavigationItem-leftBarButtonItems、rightBarButtonItems 的间距
比较有意思的是,iOS 26 之后如果使用 Liquid Glass,leftBarButtonItems 和 rightBarButtonItems 中 item 之间的间距和最小宽度会有所变化,下面是我通过真机测试整理出来的。
iOS 26 以上:
1. 距离屏幕:
- 20pt。
2. UIBarButtonItem 之间: | |
|---|---|
hidesSharedBackground == true && sharesBackground == true: | 12pt。 |
hidesSharedBackground == true && sharesBackground == false: | 12pt。 |
hidesSharedBackground == false && sharesBackground == true: | 16pt。 |
hidesSharedBackground == false && sharesBackground == false: | 12pt。 |
3. UIBarButtonItem 最小宽度: | |
|---|---|
hidesSharedBackground == true && sharesBackground == true: | 44pt。 |
hidesSharedBackground == true && sharesBackground == false: | 44pt。 |
hidesSharedBackground == false && sharesBackground == true: | 48pt。 |
hidesSharedBackground == false && sharesBackground == false: | 52pt。 |
iOS 26 以下:
1. 距离屏幕:
- 16pt。
2. UIBarButtonItem 之间:
- 8pt。
3. UIBarButtonItem 最小宽度:
- 无。
九、UIButton.Configuration
9.1 关于 UIButton 初始化方法 UIButton(configuration:) 的选择
UIButton 在 iOS 15 之前有一个最大的问题是,系统没有支持调整标题和图片位置的 API,需要开发者实现一个继承 UIButton 的子类自己写调整逻辑。
好在,现在的用户基本很少使用 iOS 15 以下的系统(对标微信),所以,可以直接使用 UIButton 在 iOS 15 开始提供的新 API,通过 Configuration 设置多样化的 UIButton。
但是,关于按钮这部分,我的想法是:
-
没有特殊要求,不需要改变标题和图片位置的,使用基础
UIButton创建就可以。 -
如果需要改变标题和图片的位置,间距,设置子标题等,使用 iOS 15 开始提供的新 API 创建按钮。
9.2 使用 UIButton(configuration:) 创建按钮,在 iOS 26 上遇到的背景、圆角和描边的问题
使用
UIButton(configuration:)创建按钮时,背景由UIBackgroundConfiguration绘制,layer上的圆角与描边无法作用于它,只修改button.layer.cornerRadius和borderWidth,它绘制的背景可能没有跟着变。
解决方法,将 UIButton 上 layer 的 cornerRadius、borderColor、borderWidth 的值,赋值给 UIBackgroundConfiguration:
extension UIButton {
func syncCornerStyleToConfigurationBackground() {
guard var configuration else { return }
var background = configuration.background
var needsUpdate = false
if background.cornerRadius != layer.cornerRadius {
background.cornerRadius = layer.cornerRadius
needsUpdate = true
}
let strokeColor = layer.borderColor?.uiColor
if background.strokeColor != strokeColor {
background.strokeColor = strokeColor
needsUpdate = true
}
if background.strokeWidth != layer.borderWidth {
background.strokeWidth = layer.borderWidth
needsUpdate = true
}
guard needsUpdate else { return }
configuration.background = background
self.configuration = configuration
}
}
设置 layer 的 cornerRadius、borderColor、borderWidth 后,调用 syncCornerStyleToConfigurationBackground 即可。