
从 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 使用第二种方式,所以内矩形贴近右下角时,另外三个角也会跟着变圆。这是统一模式的预期效果。
新增的"不同内圆角容器"开关,则同时改变两件事:
- 外部容器改成四种不同的半径。
- 内部矩形切换到
.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 官方入口:
- UICornerConfiguration
- UICornerRadius.containerConcentric(minimum:)
- NSViewCornerConfiguration
- NSView.effectiveCornerRadii
- NSView.viewDidChangeEffectiveCornerRadii()
本文代码摘自项目或按项目逻辑简化。标注"放在子类中"的片段需结合所在类型使用;截图与数值只对应文中说明的环境和布局。