iOS 26 适配

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 或更新系统上的应用。

Scene 生命周期迁移说明。

iOS 26 也把 AppDelegate 中一批界面生命周期和链接处理方法标记为弃用。这里的弃用不等于方法被删除,但采用 Scene 后,界面回调要从 Scene 的入口处理。

1.2 添加 Scene 配置

项目只支持单个窗口的,设置不支持多场景就可以了:UIApplicationSupportsMultipleScenes 设置为 false。

先创建 SceneDelegate.swift,然后在 Info.plist 中添加:

xml 复制代码
<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 和连接回调:

swift 复制代码
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()
    }
}
  1. 创建 Window 时使用 UIWindow(windowScene:),让它和当前连接的 Scene 关联。

  2. 窗口保存在 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 拒绝上传。

TN3208:启动页的 App Store 要求


三、Liquid Glass

3.1 先看系统组件

iOS 26 的 Liquid Glass ,也就是液态玻璃,会影响导航栏、TabBar、工具栏和系统按钮。

使用 iOS 26 或更新 SDK 构建,并在支持新设计的系统上运行时,系统标准组件默认采用新的设计;仍有效的旧设计兼容配置会影响实际外观。

Adopting Liquid Glass

所以换了 SDK 之后,先看一下系统栏和按钮。

  • 原来只有一个图标的地方,可能多了背景;

  • 原来自己画了背景的控件,也可能和系统背景叠在一起。

  1. 如果继续使用系统设计,就调整颜色、文字和布局。

  2. 如果要保留自己的外观,再处理背景、按钮分组,或者使用自定义组件。

3.2 UIDesignRequiresCompatibility

暂时需要保留旧设计,可以在 Info.plist 中添加:

xml 复制代码
<key>UIDesignRequiresCompatibility</key>
<true/>

这个字段用于启用旧设计兼容模式。不过,使用 iOS 27 SDK 构建时,系统会忽略它。

UIDesignRequiresCompatibility

构建时使用的 SDK 在 iOS 26、iOS 27 上运行,配置为 true
Xcode 26 的 SDK 仍遵守旧设计兼容配置
Xcode 27 的 SDK 忽略该配置

设置深色模式、修改栏的底色,只是在调整外观,也不能当成关闭液态玻璃的开关。后面再分别看 TabBar、导航栏和按钮如何处理。


四、push、pop 和侧滑返回时漏底

iOS 26 更新了系统导航转场,转场过程中出现的圆角裁切,会让页面下面的背景露出来。

Apple 在 WWDC25 介绍了新的标准导航滑动转场。

可以这样理解:

  1. 页面正常显示时铺满屏幕,底层是什么颜色看不出来;push、pop 或侧滑返回时,页面移动并出现圆角,被裁掉的边角就会露出底层。

  2. 如果页面是深色,底层却是白色,就会看到四角漏白或短暂闪白。

不过,露出的不一定直接是 UIWindow,也可能是 UINavigationController.view 或其他父容器。因此,适配时需要检查这几层:

swift 复制代码
// 创建窗口时
window.backgroundColor = pageBackgroundColor

// 导航控制器的 viewDidLoad 中
view.backgroundColor = pageBackgroundColor

// 页面控制器的 viewDidLoad 中
view.backgroundColor = pageBackgroundColor

这里的 pageBackgroundColor 指项目中统一的背景底色。对于底色统一的页面,把窗口、导航容器和页面的背景保持一致,就能处理这种圆角转场漏底。


五、TabBar 适配

5.1 使用原生 UITabBarController

准备采用系统的新设计,可以继续使用 UITabBarController。先把颜色、文字和滚动时的外观配好。

下面这段放在 TabBar 控制器的 viewDidLoad 中:

swift 复制代码
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 也要一起检查。没有单独设置时,系统会根据普通外观派生滚动边缘外观,背景可能变透明。希望两个状态一致,就像上面这样赋同一份配置。

UITabBar.scrollEdgeAppearance

5.2 使用 CYLTabBarController

如果不准备显示液态玻璃,需要保留平坦的 TabBar,可以考虑 CYLTabBarController。

这里要选对模式。库的普通模式仍使用系统 TabBar,安装之后并不会自动消除液态玻璃 。需要平坦样式时,选择 CYLFlatDesignTabBar 组件。

在继承 CYLTabBarController 的控制器中设置:

objectivec 复制代码
- (void)viewDidLoad {
    self.tabBarStyleType = CYLTabBarStyleTypeFlatDesign;
    [super viewDidLoad];
}

注意顺序,样式必须在 [super viewDidLoad] 之前设置,父类才能按指定模式创建组件。

FlatDesign 模式会隐藏系统 TabBar,使用库自己的组件绘制,并维护对应安全区。


六、导航栏返回手势处理

6.1 使用系统全屏返回手势

iOS 26 之前 ,系统只支持扣边返回,想要支持全屏侧滑返回,需要自己实现全屏侧滑返回手势处理。iOS 26 开始 提供了 interactiveContentPopGestureRecognizer,可以从内容区域触发返回。

interactiveContentPopGestureRecognizer

与使用 interactivePopGestureRecognizer 一样,在自定义导航控制器中实现:

swift 复制代码
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
    }
}

在某个页面需要禁止返回时,把对应手势禁用 ;离开后,下一个页面要按自己的规则重新设置。可以写一个专门处理导航栏返回手势的基类:

swift 复制代码
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
            }
        }
    }
}
  1. 在 viewWillDisappear 方法中,手势开启,

  2. 在 viewDidAppear 方法中,可以设置某个页面禁止手势。

子类只需要继承 PopGestureController,通过重写 isSwipeBackEnabled 决定页面手势是否禁止。

6.2 使用 JXSegmentedView、JXPagingView 与系统返回手势的冲突

相信绝大多数 iOS 开发都会使用 JXSegmentedView、JXPagingView 这两个库,使用这两个库必然会遇到与系统返回手势的冲突的问题。

iOS 26 的全屏返回手势处理比较简单,只需要开启就好,侧滑到最左边的页面时,再侧滑会触发导航栏侧滑返回手势。

主要是 iOS 26 以下扣边返回手势处理,需要保证扣边返回手势优先于 UIScrollView 的滚动手势 ,可以在控制器中提供 configureScrollViewPopGestureConflictResolution(for:) 方法:

swift 复制代码
    /// 配置使用 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 为例:

swift 复制代码
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:) 方法:

swift 复制代码
    func scrollViewClass(in listContainerView: JXSegmentedListContainerView) -> AnyClass {
        return SegmentedListContainerScrollView.self
    }
    
    func scrollViewClassInListContainerView(in pagingView: JXPagingView) -> AnyClass? {
        return PagingListContainerScrollView.self
    }

七、导航栏适配

7.1 使用系统导航栏

先来看导航控制器自带的导航栏 。需要明确的底色时,可以创建 UINavigationBarAppearance:

swift 复制代码
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:

swift 复制代码
navigationItem.title = title
navigationItem.rightBarButtonItem = item

7.2 在页面里放一个导航栏

如果需要导航栏跟着页面移动,或者单独控制背景,可以自己封装一个 NavigationView 放在每个页面上。

如果不需要使用以前设置系统导航栏的方式去编写代码,完全可以自己自定义。

导航栏视图上的标题、左右按钮需要像使用系统导航栏的 UINavigationItem 一样通过 UIBarButtonItem 设置的,可以直接在 NavigationView 中添加 UINavigationBar 和 UINavigationItem ,通过设置 navigationBar.setItems([navigationItem], animated: false),就可以达到与使用系统导航栏的编写方式进行设置,可以减少迁移成本。

在 NavigationView 中使用 UINavigationBar + UINavigationItem 的方式,例子如下:
在 NavigationView 中:

swift 复制代码
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 中:

swift 复制代码
/// 项目统一的导航控制器,负责在共享导航栏、页面导航栏和隐藏导航栏之间切换。
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 中:

swift 复制代码
/// 页面使用导航栏的方式。
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,所以定义导航栏高度代码如下:

swift 复制代码
/// 导航栏高度
var kNavigationBarHeight: CGFloat {
    if #available(iOS 26.0, *) {
        return 54
    } else {
        return 44
    }
}

/// 导航栏视图高度
var kNavigationViewHeight: CGFloat { 
    return kTopSafeArea + kNavigationBarHeight 
}

八、UIBarButtonItem

8.1 按钮背景和分组

iOS 26 之后,导航栏上的图标可能多出背景,相邻按钮也可能共用一块背景。

先看两个属性:

swift 复制代码
if #available(iOS 26.0, *) {
    item.hidesSharedBackground = true
    item.sharesBackground = false
}
  • hidesSharedBackground 控制是否隐藏系统给 Item 绘制的背景,默认是 false。

  • sharesBackground 控制是否和相邻 Item 共用背景,默认是 true。

hidesSharedBackground、sharesBackground

上面的写法适合不需要系统背景的按钮。如果想保留背景,但按钮分别显示,可以这样写:

swift 复制代码
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:

swift 复制代码
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 即可。

相关推荐
m0_587383002 小时前
无人自助拼豆系统落地开发实战指南 核心逻辑实现与部署经验分享
前端·javascript·vue.js·人工智能·经验分享·需求分析
zhaiyy2 小时前
HTML Popover API 实践
前端·javascript·html
牧艺2 小时前
SPA 也能像原生 App:View Transitions API 从原理到可上线实战
前端·javascript·css
Dovis(誓平步青云)2 小时前
现场问题先关闭还是先验收,建筑任务怎样处理依赖
前端·javascript·数据库·mysql·音视频
用户38034165882973 小时前
崩溃率从 0.3% 到 0.04%:我们做对的不是修 bug,是让每个崩溃只发生一次
ios·swift
用户84298142418103 小时前
压缩 ≠ 加密:前端代码保护的三层实践与踩坑记录
前端·javascript
前端一课3 小时前
别把人生过成一场等待退休的倒计时
前端
CENTER SAYS3 小时前
企业400电话办理全流程详解与材料准备(实操向)
前端·crm
:mnong3 小时前
Web 三维可视化技术对比:ParaView Web vs 本项目 wasm + Three.js
前端·webgl·opengl·opencl·科学可视化