Xcode 27 上线后,导航栏「长歪」了:Liquid Glass 实战排坑

11 阅读8分钟

用 Xcode 27(iOS 27 SDK)重新编译同一套 UIKit 工程后,很多团队会遇到同一种「似曾相识」的不适感:

  • 导航栏右上角多个按钮被拆成一串玻璃圆,间距突然拉开
  • 搜索框像没了底,白茫茫一片分不清输入区域
  • 自定义顶栏顶进状态栏,和系统时间、电量叠在一起
  • Info.plist 里写了 UIDesignRequiresCompatibility = YES,结果完全不管用

这不是错觉。Apple 已经写明:用 iOS 27 SDK 构建时,兼容开关会被忽略,Liquid Glass 相关表现会强制进来。本文不讲 Scene 迁移和提审清单(那是另一篇文章),只聚焦一件事——导航栏、搜索栏、自定义顶栏怎么在「去不掉玻璃」的前提下,把体验拉回接近 iOS 26 的可用状态。

文中代码可直接改类名复用,不绑定任何具体 App。


先认清三件事

1. 兼容开关救不了 Xcode 27 包

UIDesignRequiresCompatibility 在「用旧 SDK 打的包跑新系统」时还有意义;一旦换成 Xcode 27 出包,系统会忽略它。继续指望 plist 挡 Liquid Glass,会耽误排期。

2. 导航栏变丑,往往不是「高度从 44 变成 54」这么简单

有人一上来就 swizzle sizeThatFits、在 layoutSubviews 里改 frame,想把导航栏硬压回 44。我们踩过坑:

做法真实结果
layoutSubviews 改高度 / 约束布局死循环,启动内存暴涨到数 GB(OOM)
全局 sizeThatFits 强制 44top safe area 算乱,内容顶进状态栏

结论:别用改系统导航栏高度去「对齐旧版」。 高度交给系统;需要对齐的是观感(不透明、按钮间距、搜索底)和自定义顶栏的 safe area。

3. 首页若自己画了一套顶栏,问题在宏,不在 UINavigationBar

很多 IM / 消息首页会 hidden 掉系统导航栏,自己用 statusBarHeight + 44 拼一块自定义栏。iOS 26 多 Scene 下,启动早期 statusBarFrame 经常是 0,于是顶栏只有 44pt 贴在屏幕最顶上——看起来像「飘进状态栏」。这和是否强压系统导航栏高度是两条线,别搅在一起。


一、先把导航栏「实」回来:Opaque Appearance

Liquid Glass 下,半透明 / effect 会把标题和按钮冲得发虚。基类导航控制器里统一不透明即可:

static void ApplyOpaqueNavBarAppearance(UINavigationBar *navBar,
                                        UIColor *backgroundColor,
                                        UIColor *titleColor) {
    if (@available(iOS 13.0, *)) {
        UINavigationBarAppearance *appearance = [UINavigationBarAppearance new];
        [appearance configureWithOpaqueBackground];
        appearance.backgroundEffect = nil;
        appearance.backgroundColor = backgroundColor;
        appearance.titleTextAttributes = @{
            NSForegroundColorAttributeName : titleColor ?: UIColor.whiteColor
        };
        appearance.shadowColor = UIColor.clearColor;
        appearance.shadowImage = [UIImage new];
        navBar.standardAppearance = appearance;
        navBar.scrollEdgeAppearance = appearance;
        if (@available(iOS 15.0, *)) {
            navBar.compactAppearance = appearance;
            navBar.compactScrollEdgeAppearance = appearance;
        }
    }
    navBar.translucent = NO;
    navBar.barTintColor = backgroundColor;
    navBar.backgroundColor = backgroundColor;
}

IM 栈若另有一套 UINavigationController 子类,同样在 viewWillAppear 刷一遍,避免「主工程 opaque、IM 栈仍玻璃」。

单按钮去共享玻璃底(iOS 26+):

if (@available(iOS 26.0, *)) {
    item.hidesSharedBackground = YES;
    item.sharesBackground = NO;
}

注意:只对「每一个」item 开 hidesSharedBackground,多按钮时反而会间距爆炸——下一节解决。


二、多按钮间距爆炸:合并成一个 Stack

现象:聊天页右上角「通话 + 更多」等,在 iOS 27 上中间空出一大块,像两个独立玻璃球。

原因可以简化理解:系统按「各自一块共享背景」排布;关掉共享背景后,旧版那种「挤在一起」的视觉就不在了。

稳定做法是:多于一个 item 时,提成 UIView,塞进 UIStackView,再包成单个 UIBarButtonItem。间距自己控(例如 12pt)。

+ (NSArray<UIBarButtonItem *> *)compactItems:(NSArray<UIBarButtonItem *> *)items
                                  isRightSide:(BOOL)isRightSide {
    if ([NSProcessInfo processInfo].operatingSystemVersion.majorVersion < 26) {
        return items;
    }
    if (items.count <= 1) {
        return items;
    }

    NSMutableArray<UIView *> *views = [NSMutableArray array];
    for (UIBarButtonItem *item in items) {
        // FixedSpace / 空占位跳过;customView 优先,否则用 UIButton 包一层 image/title
        UIView *v = ViewFromBarButtonItem(item);
        if (!v) { return items; } // 抽不出来就别硬合并,避免弄坏系统项
        [views addObject:v];
    }
    if (views.count <= 1) { return items; }

    // rightBarButtonItems:系统约定 index0 在最右侧,Stack 从左到右要 reverse
    NSArray<UIView *> *arranged = isRightSide
        ? [[views reverseObjectEnumerator] allObjects]
        : [views copy];

    UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:arranged];
    stack.axis = UILayoutConstraintAxisHorizontal;
    stack.alignment = UIStackViewAlignmentCenter;
    stack.spacing = 12.0;
    stack.tag = 3092601; // 防重复合并

    CGFloat totalW = /* 各子视图宽度 + spacing */;
    stack.frame = CGRectMake(0, 0, totalW, 44.0);

    UIBarButtonItem *one = [[UIBarButtonItem alloc] initWithCustomView:stack];
    if (@available(iOS 26.0, *)) {
        one.hidesSharedBackground = YES;
        one.sharesBackground = NO;
    }
    return @[ one ];
}

落地时建议三层兜底,避免业务在 viewDidLoad / push 前后多次赋值漏网:

  1. Swizzle UINavigationItem 的 setLeft/RightBarButtonItem(s):
  2. 导航控制器 willShow / didShow / push 再压一次
  3. 基类 VC viewWillAppear 补一次

这样「聊天页单独合并」和「全局多按钮」可以共用一套逻辑。


三、自定义顶栏顶进状态栏:信 safeArea,别信启动瞬间的 0

典型错误

// 启动早期经常拿到 0
CGFloat h = statusBarFrameHeight + 44.0;
[customNavBar.heightAnchor constraintEqualToConstant:h].active = YES;

更稳的状态栏高度

+ (CGFloat)statusBarHeight {
    CGFloat height = 0;
    if (@available(iOS 13.0, *)) {
        UIWindowScene *scene = nil;
        for (UIScene *s in UIApplication.sharedApplication.connectedScenes) {
            if (![s isKindOfClass:UIWindowScene.class]) { continue; }
            UIWindowScene *ws = (UIWindowScene *)s;
            if (ws.activationState == UISceneActivationStateForegroundActive) {
                scene = ws; break;
            }
            if (!scene) { scene = ws; }
        }
        height = scene.statusBarManager.statusBarFrame.size.height;
        if (height < 1) {
            for (UIWindow *w in scene.windows) {
                if (w.isKeyWindow || w.windowLevel == UIWindowLevelNormal) {
                    height = w.safeAreaInsets.top;
                    if (height > 1) { break; }
                }
            }
        }
    }
    if (height < 1) {
        height = UIApplication.sharedApplication.statusBarFrame.size.height;
    }
    if (height < 1) {
        height = 47.0; // 刘海 / Island 兜底,避免顶栏塌成「只有 44」
    }
    return height;
}

用 safeArea 驱动高度,并在 inset 变化时刷新

- (CGFloat)customNavBarHeight {
    CGFloat top = self.view.safeAreaInsets.top;
    if (top < 1) { top = [self.class statusBarHeight]; }
    if (top < 1) { top = 47.0; }
    return top + 44.0;
}

- (void)viewSafeAreaInsetsDidChange {
    [super viewSafeAreaInsetsDidChange];
    [self.navBarHeightConstraint setOffset:[self customNavBarHeight]];
    [self.statusBarBgHeightConstraint setOffset:self.view.safeAreaInsets.top > 1
        ? self.view.safeAreaInsets.top
        : [self.class statusBarHeight]];
}

内容区用「顶贴自定义栏底、底贴容器」即可,不要再用 屏幕高 - 错误宏高度 - TabBar 算死高——宏一旦算错,整页会一起错。

若子列表还按「没有导航栏 → 再加 StatusBar+44」做偏移,父级已画自定义顶栏时,记得关掉这层叠加,否则会出现双倍顶距或错位。


四、搜索框「看不见」:先查主题色是不是近白

全页搜索(导航栏 titleView +「取消」)在 iOS 27 上最常见的反馈是:输入框没背景。

排查时先看主题 / 动态色里类似 search_textfield_bg_color 的值。我们遇到的真实配置是 #FEFEFE——在白色导航上等于没画。你在代码里写 fallback #F2F2F7 也没用,因为动态色优先返回主题里的近白。

推荐组合(全页搜索专用)

  • 导航栏 / titleView 区域:白底(opaque)
  • 输入框:硬编码 #E5E5EA(接近 systemGray5) + 圆角 10
  • iOS 26+:先清空系统搜索底图,再设颜色,否则 backgroundColor 经常不生效
  • viewDidLayoutSubviews 再刷一次,防止系统盖掉
- (void)applySearchFieldStyle {
    UIColor *headerBg = UIColor.whiteColor;
    UIColor *fieldBg = [UIColor colorWithRed:0xE5/255.0
                                       green:0xE5/255.0
                                        blue:0xEA/255.0
                                       alpha:1.0];

    self.searchBar.backgroundImage = [self imageWithColor:headerBg];
    self.searchBar.barTintColor = headerBg;

    UITextField *tf = self.searchBar.searchTextField;
    tf.borderStyle = UITextBorderStyleNone;

    // 关键:先干掉系统默认底,再上自己的色
    [self.searchBar setSearchFieldBackgroundImage:[UIImage new]
                                         forState:UIControlStateNormal];
    tf.backgroundColor = fieldBg;
    tf.layer.backgroundColor = fieldBg.CGColor;
    tf.layer.cornerRadius = 10.0;
    tf.layer.masksToBounds = YES;

    // 可选:36pt 可拉伸圆角底图,兼顾高度观感
    UIImage *fill = /* 60×36、圆角 10、颜色 fieldBg,左右 cap 10 */;
    [self.searchBar setSearchFieldBackgroundImage:fill
                                         forState:UIControlStateNormal];

    // 给 _UISearchBarSearchFieldBackgroundView 上色;Glass 相关子视图可 hidden
    [self paintSystemSearchFieldBackground:tf color:fieldBg];
}

另外两个小坑:

  1. 不要把搜索底视图一律 hidden——以前为了去玻璃这么干,结果只剩图标和 placeholder,更像「没背景」。
  2. 入口搜索条(灰条点一下再进全页)和全页搜索的逻辑要分开:入口可以跟列表背景;全页在 isEntrance == NO 时若 backgroundColor = superview.backgroundColor,会再次被刷成导航白,把你刚设的灰底氛围冲掉。

取消按钮建议做成 titleView 里的文字「取消」,而不是完全依赖系统 cancel 样式,减少 Liquid Glass 再挤窄搜索区域的概率。


五、一张「别做 / 要做」对照表

别做要做
指望 UIDesignRequiresCompatibility按真机 Liquid Glass 改 Appearance / 按钮 / 搜索
layoutSubviews 改导航栏 frame永远不要;会 OOM
全局 sizeThatFits 压 44高度交给系统;自定义栏用 safeArea
主题近白当搜索底全页搜索硬编码可见灰(如 #E5E5EA)
隐藏整层 SearchFieldBackground上色或配合 setSearchFieldBackgroundImage
启动瞬间 statusBarFrame 算死高safeAreaInsets.top + 44,inset 变化再刷

六、建议验收路径(发文读者可自测)

  1. 冷启动进带自定义顶栏的首页:第一行业务 UI 必须完整落在状态栏下方。
  2. 打开多按钮聊天页:右上角间距应接近旧版,而不是两个大玻璃球。
  3. 进入全页搜索与搜索结果页:白导航上应能清楚看到灰色圆角输入框 +「取消」。
  4. 反复进出、旋转(若支持):顶栏高度不回弹成「贴状态栏」。
  5. 观察启动内存:确认没有导航栏 layoutSubviews 改 frame 的「复活」。

写在最后

Liquid Glass 不是「加一层毛玻璃」这么温和。对存量 UIKit 来说,它拆的是默认假设:导航栏高度、按钮排布、搜索底色、甚至你多年没动过的 statusBar + 44 宏。

这轮适配里,最值钱的不是又写一套主题色,而是三条原则:

  1. 玻璃去不掉时,用 opaque + 合并按钮 + 可见搜索底,换回可读性
  2. 永远不要用改 UINavigationBar 高度去赌旧版像素
  3. 自定义顶栏只认 safeAreaInsets,不认启动瞬间的 0

如果你也在用 Xcode 27 编老工程,不妨先打开全页搜索和多按钮聊天页——这两处通常最能一眼看出系统已经不再「兼容你的旧审美」。欢迎在评论区说说:你们是系统导航栏为主,还是大量自定义顶栏?两条路径的坑差很多。


延伸阅读