同心圆角
嵌套的圆角形状只有共享它们的角部中心点时看起来才正确:内侧半径等于外侧半径减去两者之间的间距。如果搞错了,角部会看起来 外扩(内角比外角更圆)或 内缩(内角比外角方得多)。Apple 的三种形状类型:
- Fixed(固定) — 恒定半径(
RoundedRectangle(cornerRadius:))。 - Capsule(胶囊) — 半径是高度的一半;本质上是同心的(按钮、徽章、药丸形状)。
- Concentric(同心) — 半径 = 父级半径 − 内边距,由系统计算得出。
API(iOS 26, macOS 26)
// 容器声明其形状。必须是 RoundedRectangularShape:
// RoundedRectangle, UnevenRoundedRectangle, Rectangle, Capsule, Circle。
card.containerShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
// 内部内容与其保持同心,绝不会低于最小值(内容是独立或远离容器角部时的半径)。
ConcentricRectangle(corners: .concentric(minimum: .fixed(16)), isUniform: false)
.concentric(minimum:)是一个 下限,而不是目标:半径 = max(容器 − 内间距, 最小值)。如果容器对于内容的 padding 来说太紧,请增大容器的半径——而不是降低最小值。isUniform: true使每个角具有相同的半径(例如在地图的一个边缘附近浮动的卡片);false则仅使靠近容器角部的角为同心(Apple 的 sheet 样式)。- 如果没有
containerShape,同心形状会针对窗口/屏幕角部进行解析——因此全屏视图的顶部内容可以跟随设备的边框。 ConcentricRectangle不是RoundedRectangularShape:它不能作为容器使用。本身被嵌套的容器(例如地图上的卡片)需要将其 结果 半径声明为RoundedRectangle(地图半径 − 内间距),以供其自身的子视图使用。matchedTransitionSource { $0.clipShape(…) }只接受RoundedRectangle——请传入内容通常的半径。- 在 iOS 26 之前:回退到使用最小半径的
RoundedRectangle。对RoundedRectangle使用containerShape仍然可以编译(旧的InsettableShape重载)。
可复用的角部比例方案
从内层推导外层,而不是反过来:为照片和小卡片选择一个 内侧 半径,并使每个容器的半径为 内侧半径 + 内间距。这样任何距离容器一个内间距的内容都会精确地得到 内侧半径。
enum Corners {
static let inner: CGFloat = 16 // 照片、小卡片
static let map: CGFloat = 28 // 地图和大型媒体
static let mapInset: CGFloat = 12 // 地图上的卡片 → 16
static func container(padding: CGFloat) -> CGFloat { inner + padding } // 12 pt → 28
static func rect(_ radius: CGFloat) -> RoundedRectangle {
RoundedRectangle(cornerRadius: radius, style: .continuous)
}
/// 嵌套在容器中的内容:同心,永不低于 `minimum`。
static func nested(minimum: CGFloat, uniform: Bool = false) -> AnyShape {
if #available(iOS 26, *) {
AnyShape(ConcentricRectangle(corners: .concentric(minimum: .fixed(minimum)), isUniform: uniform))
} else {
AnyShape(rect(minimum))
}
}
}
用法:
// 一张卡片:根据其内间距得到的半径,声明为容器。
let shape = Corners.rect(max(requested, Corners.container(padding: padding)))
VStack { photo }.padding(padding)
.background(shape.fill(.surface))
.containerShape(shape)
// 任何位置的照片:与它的容器保持同心,独立时为 16。
let photo = Corners.nested(minimum: Corners.inner)
photo.fill(.placeholder).overlay { image }.clipShape(photo)
// 地图上带有玻璃效果卡片,卡片内包含缩略图。
map.clipShape(Corners.rect(Corners.map)).containerShape(Corners.rect(Corners.map))
.overlay(alignment: .bottom) {
HStack { Corners.nested(minimum: 10).fill(…).frame(width: 64, height: 64) … }
.padding(12)
.glassEffect(.regular, in: Corners.nested(minimum: Corners.map - Corners.mapInset, uniform: true))
.containerShape(Corners.rect(Corners.map - Corners.mapInset))
.padding(Corners.mapInset)
}
检查清单
- 每个容纳圆角内容的容器都声明了
.containerShape(_:)。 - 嵌套内容使用
ConcentricRectangle(或辅助方法),并设置了合理的minimum。 - 容器半径 ≥ 内容半径 + 内间距;绝不在小的容器内部放入更大的半径。
- 裁剪、背景、玻璃效果和点击测试(
contentShape)都使用相同的形状。 - 按钮、徽章和药丸形状使用 Capsule;在手机上注意屏幕边缘的额外边距。
- 已放置 iOS 18 的回退方案。
验证方法
在 macOS 26+ 上离屏渲染(ImageRenderer,在辅助方法的副本中使用 #available(iOS 26, macOS 26, *)),并使用高对比度填充:一个容器、一个内容(垫一个内间距)、同一内容独立显示、以及一个放在更大容器中的浮动卡片。角部应明显共享中心点;独立显示的内容则展示最小值。