为 iPhone Duo 做准备:containerConcentric 自适配指南

从 RectTest 演示项目出发,聊聊 iOS 与 macOS 怎样处理嵌套圆角,以及"四个角长得不一样"时该怎么办。文中的郭靖、黄蓉故事是为解释技术编写的戏说,并非金庸原著情节。

核对环境:Xcode 27.0;项目最低支持 iOS 26.0、macOS 27.0。文中运行证据来自 iOS 27.0 模拟器与本机 macOS 27.0,不能据此声称已经实测所有支持版本。

第一回:靖哥哥一掌下去,四个角都圆了

襄阳城里要修一座小院。

郭靖负责外墙,黄蓉负责院中亭台。郭大侠办事向来实在,量了外墙圆角,又量了亭子的圆角,大笔一挥:都是二十八。

"整整齐齐。"他说。

黄蓉把亭子往墙角一推,停住了。

"靖哥哥,你看这条缝,像不像有人拿降龙十八掌啃过?"

这事做过卡片界面的开发者大概都见过:外面一个圆角容器,里面一个圆角矩形,两边的半径看起来很讲究,嵌在一起却总有些别扭。尤其是内层贴近外层时,直边之间的距离挺匀,拐到圆角附近就忽宽忽窄。

毛病通常不在半径够不够大,而在内外两条轮廓有没有配合。

先拿最简单的圆弧说话:外层半径为 R,内层沿两条相邻边等距缩进 d,如果要让两条圆弧共享圆心,内层半径应当是 R - d。例如 28 - 8 = 20。

这个算式适合解释同心关系,不是 Apple 自适应算法的完整实现。实际视图还会移动、缩放,有最小半径限制;连续圆角也不能完全当成四分之一圆来算。把这个减法写死进布局里,后面很容易修成另一座襄阳城。

RectTest 做的事很简单:浅绿色是容器,深绿色是可以拖动的内矩形。我们移动它、改变容器半径,看系统怎样调整内矩形的圆角。

图 1:iPhone 18 Pro 模拟器运行截图。容器半径为 28 pt,内矩形靠近右下角,两边留出 8 pt;当前统一模式显示四角均为 20 pt。这个数值是该位置的运行结果,不是所有位置的通用公式。

第二回:桃花岛的口诀,只有半句写在 minimum 里

黄蓉递给郭靖一张纸,上面没写招式,只有一行 Swift:

swift 复制代码
inner.cornerConfiguration = .corners(
    radius: .containerConcentric(minimum: 0)
)

郭靖看了半晌:"这里没有二十八。"

"对。你终于不用替每个角算命了。"

这行代码值得拆成三层看:

部分 负责什么
UIView.cornerConfiguration 把圆角配置交给视图
.corners(radius:) 将这套半径策略分别用于四个角
.containerConcentric(minimum:) 根据视图与容器的几何关系计算半径,并受最小值约束

最容易看走眼的是第二层。同一个策略,不代表同一个结果。

四个角都使用 containerConcentric,但它们相对容器的位置不一样,最后算出的数值可以不一样。Apple 对 corners(radius:) 的定义就是独立应用到各角。本机 UIKit 与 AppKit 的 SDK 头文件也明确写了:配合同心策略时,各角允许解析成不同的半径。

minimum 则是下限,不是建议值,也不是额外增加的半径。设为 8,就不要指望受该策略控制的角变成 0。需要演示直角,就从 0 开始。它约束的是内部这套半径策略,不会把外部容器也改成直角。containerConcentric(minimum:) 官方说明

同心圆角也没有替我们包办布局。矩形放在哪里、间距留多少、拖动能否越界,仍然是应用自己的事。RectTest 的边界限制和圆点吸附就是自己写的;系统负责圆角的几何解析。

第三回:左右互搏,也不能拿 uniform 代替 independent

郭靖想起周伯通的左右互搏,觉得四个角各练各的,甚是合理。

然后他写成了:

swift 复制代码
inner.cornerConfiguration = .uniformCorners(
    radius: .containerConcentric(minimum: 0)
)

黄蓉叹了口气:"你这是让四个人分头练功,最后又要求他们摆同一个姿势。"

这两个 API 只差一个名字,设计意图却很不一样:

swift 复制代码
// 四角分别解析,允许出现不同半径。
.corners(radius: .containerConcentric(minimum: 0))

// 四角统一,维持常见的等圆角矩形外观。
.uniformCorners(radius: .containerConcentric(minimum: 0))

原来的 RectTest 使用第二种方式,所以内矩形贴近右下角时,另外三个角也会跟着变圆。这是统一模式的预期效果。

新增的"不同内圆角容器"开关,则同时改变两件事:

  1. 外部容器改成四种不同的半径。
  2. 内部矩形切换到 .corners(radius:),允许四角独立适配。

只做第一步是不够的。外墙已经修成四种样子,亭子还坚持四角统一,黄蓉看了还是要返工。

项目把外层半径安排成下面这样,R 继续由原来的滑块控制:

物理位置 半径规则 R = 28 时
左上 0 0 pt,直角
右上 R 28 pt
左下 0.5R 14 pt
右下 1.5R 42 pt

这里说的是物理左、右,不是随文字方向变化的 leading、trailing。后面借用 SwiftUI 形状生成绘制路径时,要记得自己正在映射哪套坐标。

第四回:先过 iOS 这一关------容器报家门,内层见招拆招

UIKit 的配置可以直接赋给视图。以下是项目核心逻辑的简化版本:

swift 复制代码
import UIKit

@available(iOS 26.0, *)
func configureCorners(
    container: UIView,
    inner: UIView,
    radius: Double,
    minimum: CGFloat,
    usesDifferentCorners: Bool
) {
    if usesDifferentCorners {
        container.cornerConfiguration = .corners(
            topLeftRadius: .fixed(0),
            topRightRadius: .fixed(radius),
            bottomLeftRadius: .fixed(radius / 2),
            bottomRightRadius: .fixed(radius * 1.5)
        )
    } else {
        container.cornerConfiguration = .uniformCorners(
            radius: .fixed(radius)
        )
    }

    let adaptive = UICornerRadius.containerConcentric(minimum: minimum)
    inner.cornerConfiguration = usesDifferentCorners
        ? .corners(radius: adaptive)
        : .uniformCorners(radius: adaptive)
}

前提也得摆明白:本例中,inner 是 container 的子视图。

swift 复制代码
container.addSubview(inner)

外层给出可被系统理解的圆角配置,内层声明自己的适配策略。内层并没有写"左上必须为零"之类的设备特判;容器的形状和几何关系才是依据。UIKit 圆角配置文档

项目用 UIPanGestureRecognizer 更新内矩形的位置。动画播放时,则由 CADisplayLink 逐帧推进位置,让系统在移动过程中持续解析圆角。这个显示链接是项目的动画实现,不是使用同心圆角 API 的必选配件。

要知道系统到底算了多少,可以读取:

swift 复制代码
// 放在 UIView 子类中。
override func layoutSubviews() {
    super.layoutSubviews()

    let topLeft = effectiveRadius(corner: .topLeft)
    let topRight = effectiveRadius(corner: .topRight)
    let bottomLeft = effectiveRadius(corner: .bottomLeft)
    let bottomRight = effectiveRadius(corner: .bottomRight)

    // 比较数值有无变化,再通知界面更新。
}

按当前 SDK 的说明,在 layoutSubviews() 等受支持的更新方法里读取这个值,会建立半径变化时的自动失效关系。

还有个小坑:effectiveRadius(corner: .allCorners) 返回的是这些角里的最大半径,不是四个数值的集合。统一模式拿它显示一个数没问题,独立模式继续这样读,就会把差异藏起来。

RectTest 因此改为显示两行:上面两个角,下面两个角。四个人练了四套功夫,总得让观众看清谁在出掌。

这些读取与失效语义可核对 Xcode SDK 的 UIView.h 中 CornerConfiguration 分类,以及 effectiveRadius(corner:)。

第五回:转战 macOS------内功算出来了,招式还得自己画

到了 AppKit,郭靖照着 iOS 抄了一遍,发现不能照样给 cornerConfiguration 赋值。

黄蓉倒不意外:"换了门派,先翻秘籍。"

两边主要差异如下。这里的版本要求针对本文讨论的原生 API,不代表所有 SwiftUI 圆角功能都要这些版本。

对照项 UIKit AppKit
本文 API 的最低系统版本 iOS 26.0 macOS 27.0
半径类型 UICornerRadius NSViewCornerRadius
配置类型 UICornerConfiguration NSViewCornerConfiguration
如何提供配置 设置 UIView.cornerConfiguration 在 NSView 子类中重写只读属性
自适应下限写法 .containerConcentric(minimum: 0) .containerConcentric(0)
如何读取结果 effectiveRadius(corner:) effectiveCornerRadii
本例如何显示圆角 UIKit 应用配置 在回调中把解析结果用于图层绘制

版本信息与签名已按 Xcode 27.0 自带 SDK 核对。AppKit 的配置入口见 NSViewCornerConfiguration。

内部视图的配置其实很短:

swift 复制代码
// 放在 NSView 子类中。
override var cornerConfiguration: NSViewCornerConfiguration? {
    let radius: NSViewCornerRadius = .containerConcentric(minimumCornerRadius)
    return usesDifferentCorners
        ? .corners(radius: radius)
        : .uniformCorners(radius: radius)
}

接下来要接住系统返回的结果:

swift 复制代码
override func viewDidChangeEffectiveCornerRadii() {
    super.viewDidChangeEffectiveCornerRadii()
    applyResolvedCorners()
}

func applyResolvedCorners() {
    guard let radii = effectiveCornerRadii else { return }
    // 用 topLeft、topRight、bottomLeft、bottomRight 分别绘制。
}

effectiveCornerRadii 是解析后的半径,配置为空时结果也可以为空。回调的职责是让视图按需应用这些半径。原生 API 把几何信息交给你,并没有替这个项目的自定义填色视图写完绘制逻辑。解析结果、变化回调

项目还在位置、尺寸和配置参数变化时调用 invalidateCornerConfiguration(),让配置及其依赖重新计算。例如:

swift 复制代码
var minimumCornerRadius: CGFloat = 8 {
    didSet {
        if oldValue != minimumCornerRadius {
            invalidateCornerConfiguration()
        }
    }
}

override func setFrameOrigin(_ newOrigin: NSPoint) {
    super.setFrameOrigin(newOrigin)
    invalidateCornerConfiguration()
}

这不是 UIKit 那套直接赋值代码的逐字翻译。共享的是"容器提供形状,内层声明策略"的思路,接入点仍然各走各的。

第六回:一个 cornerRadius,装不下四门武功

最初的 Mac 版本,在拿到四角结果后干了这么一件事:

swift 复制代码
layer.cornerRadius = max(
    radii.topLeft, radii.topRight,
    radii.bottomLeft, radii.bottomRight
)

统一圆角演示里,这么处理说得过去。到了独立圆角模式,就相当于四位师父各教一招,郭靖最后只记住声音最大的那位。

CALayer.cornerRadius 只有一个数,不能完整表达 0、28、14、42 这种配置。maskedCorners 能选哪些角变圆,也不等于能给每个角指定不同半径。

项目的做法是:四角相同时继续用 layer.cornerRadius;不同时,按四个半径生成连续圆角路径,用 CAShapeLayer 做 mask。下面截取的是非统一分支,radii 为项目的 DemoCornerRadii:

swift 复制代码
layer.cornerRadius = 0

let shape = UnevenRoundedRectangle(
    topLeadingRadius: radii.topLeft,
    bottomLeadingRadius: radii.bottomLeft,
    bottomTrailingRadius: radii.bottomRight,
    topTrailingRadius: radii.topRight,
    style: .continuous
)
let path = shape.path(in: CGRect(origin: .zero, size: bounds.size)).cgPath

var transform = CGAffineTransform(translationX: 0, y: bounds.height)
    .scaledBy(x: 1, y: -1)

cornerMask.frame = bounds
cornerMask.path = path.copy(using: &transform)
cornerMask.fillColor = NSColor.black.cgColor
layer.mask = cornerMask

这段里有两件事不能漏。

第一,半径仍由 AppKit 计算 。借用 UnevenRoundedRectangle 是为了生成绘制路径,不是拿 SwiftUI 重新实现一套同心算法。这里也不声称它与 UIKit 的最终轮廓逐像素一致。

第二,当前画布是默认未翻转的 NSView,坐标原点在左下;上面的形状路径按顶部方向构造,所以做了纵向翻转。如果视图改为 isFlipped == true,或者用了不同的坐标安排,就要重新检查变换。不能把这两行奉为祖传心法,见一个视图就贴一次。

mask 要同时覆盖背景和子视图,这样容器里的圆点才不会漏到圆角之外。切回统一模式时,也要清掉 layer.mask,否则旧轮廓还在暗中发功。

图 2:直接运行项目 AppKit 画布后捕获的视图图像,不是完整应用窗口截图。容器四角为 0、28、14、42 pt;内矩形靠近左上角,最小半径为 0,此位置解析出的四角均为 0。

图 3:同一个运行时画布,把内矩形移到右下角,两边留出 8 pt。内部解析结果为左上 0、右上 0、左下 0、右下 34 pt。只有需要顺应容器的角弯了下来。

第二张图的结果很能说明问题:.corners 并不要求内部四角每次都不一样,也不是把容器的四个半径照抄进来。它允许每个角根据自己的处境作出不同响应。

第七回:开关只添一个,状态别打成一团

黄蓉给演示添了一个开关,郭靖看着很满意:"这回只改了一行吧?"

开发者听见这种话,通常会先喝一口水。

开关背后有两套最小半径状态。统一模式保留原来的默认值 8,独立模式默认 0。这样用户打开开关时能看到直角,关闭后也不会发现自己原先的设置没了。

swift 复制代码
@State private var minimumInnerRadius: Double = 8
@State private var independentMinimumRadius: Double = 0
@State private var usesDifferentCorners = false

private var activeMinimumRadius: Binding<Double> {
    usesDifferentCorners
        ? $independentMinimumRadius
        : $minimumInnerRadius
}

演示界面由 SwiftUI 持有这些值,再通过 UIViewRepresentable 和 NSViewRepresentable 传给原生画布。原生画布把圆角结果回传,SwiftUI 负责显示数值。协调器只记按钮的触发标记,没有再藏一套圆角状态。

Mac 端原先就有半径平滑过渡。改成逐角之后,平滑也必须逐角做,不能最后又压回一个最大值:

swift 复制代码
let alpha = 1 - exp(-dt / tau)
let next = current + (target - current) * alpha

这是每个角各执行一次的指数平滑示意,项目中 tau 为 0.12 秒。目标值来自系统,当前显示值来自动画过程;Mac 界面显示的是平滑后的值。UIKit 当前读数则来自系统解析结果。两端演示的几何目标相同,动画过程并非完全相同。

如果录屏时发现 Mac 的数字晚一点追上目标,先别给 Apple 提反馈。那是我们自己加的缓动,还在路上。

第八回:城修好了,别顺手宣布天下太平

做到这里,很容易冒出一个念头:既然有左上直角、其余角不同的容器,那是不是已经适配了所有非对称屏幕?

还没有。

本文实测的对象是我们明确配置过形状的绿色容器。真实设备里,窗口、根视图、安全区域、视图层级、横竖屏状态都可能参与最终布局。把一个演示容器做对,不等于已经证明应用能正确获得并跟随某台设备的屏幕轮廓。

因此,本文不把某个机型的角写进代码,也不把"左上为零"当成设备检测结果。这只是一个刻意设计的几何测试用例。以后需要适配真实屏幕,再到相应设备或模拟器里检查实际层级与系统行为。

还有旧系统。这个项目的最低版本已经设为 iOS 26 和 macOS 27,所以正文可以直接使用这些 API。若要塞进支持更早系统的应用,相关类型、属性和实现都要放进可用性边界;旧系统提供固定圆角或其他降级绘制。不要只给某一行套个 if #available,旁边却还存着一个旧系统根本不认识的类型。

这次项目的验证范围也记在这里,免得文章写着写着就把"能编译"写成"全平台无敌":

验证项 结果与范围
iOS 与 macOS 构建 Xcode 27.0 下均通过
iOS 界面 iPhone 18 Pro 模拟器已启动并获取统一模式截图
AppKit 容器四角 运行时检查得到 0、28、14、42
AppKit 内部独立适配 左上位置四角为零;右下位置仅右下为 34
AppKit 绘制方向 检查 mask 路径,左上为直角、右下为圆角
模式恢复 关闭独立模式后,内部回到统一半径,外层独立 mask 清除
iOS 新开关的完整自动交互回归 尚未完成;不能用 Mac 检查替代这项验证

试用这个演示时,可以先把最小半径设为 0,拖到四个角;再把最小半径调到 8,观察直角怎样受下限约束。最后关闭开关,看看独立的四个角如何重新统一。只盯着右下角看一次,容易漏掉左下半径和坐标方向的问题。

尾声:同心,不必同形

小院终于修好了。

郭靖绕着亭子看了一圈:"左上是直的,右下是圆的,倒也妥帖。"

黄蓉把尺子收起来:"墙长什么样,你就先看清它长什么样。别一上来就打十八掌。"

写界面也是如此。希望四角统一,就选 .uniformCorners;需要每个角顺着容器变化,就选 .corners。容器声明形状,系统解析半径,应用把结果完整地画出来。

至于四个角是不是必须一样------小院住得舒服就好,又不是丐帮排队报数。

感谢宝子们的观赏,我们下回不见不散!8-)


附录:代码与资料

Apple 官方入口:

本文代码摘自项目或按项目逻辑简化。标注"放在子类中"的片段需结合所在类型使用;截图与数值只对应文中说明的环境和布局。

相关推荐
百万蹄蹄向前冲1 小时前
一张平面图把学校做成2D游戏
前端·人工智能·后端
zeqinjie1 小时前
Flutter 获取 iPhone Duo 预留区位置
前端·flutter·ios
机器之心1 小时前
突发:Claude自主发现未知生物系统,或能编辑基因
前端·人工智能·后端
gnip1 小时前
UniApp 内嵌 H5 通信全攻略
前端·javascript
仿生狮子1 小时前
实现近乎免费之后,设计工程师还剩什么
前端·后端·设计
EatFans1 小时前
Electron 打包与自动更新完全指南:electron-builder、latest.yml、app-update.yml 与自建更新服务器实战
前端
小凯在掘金1 小时前
为什么要有访问器? 你不知道的对象属性
前端·javascript
OpenTiny社区1 小时前
HC 2026 回顾|OpenTiny NEXT 解锁 Web 应用智能化新范式
前端·开源·github
时光少年1 小时前
Android HWC退化与防治方法
前端