同心円状の角
ネストされた丸いシェイプは、角の中心を共有していると正しく見えます。つまり、内側の半径は外側の半径からそれらの間のスペースを引いたものです。これを間違えると、角はフレア(内側が外側より丸い)またはピンチ(内側がかなり四角い)に見えます。Apple の3つのシェイプタイプ:
- 固定 — 一定の半径(
RoundedRectangle(cornerRadius:))。 - カプセル — 半径は高さの半分。本質的に同心円状(ボタン、バッジ、ピル)。
- 同心円状 — 半径 = 親の半径 − パディング。システムによって計算されます。
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:)は下限であり、目標ではありません:radius = max(container − inset, minimum)。コンテナがコンテンツのパディングに対してきつすぎる場合は、コンテナの半径を上げてください — 最小値を下げないでください。isUniform: trueはすべての角に同じ半径を与えます(マップの片端近くに浮かぶカード)。falseにすると、コンテナの角に近い角のみが同心円状になります(Apple のシート)。containerShapeがない場合、同心円状のシェイプはウィンドウ/ディスプレイの角に対して解決されます。つまり、全画面ビューの上部コンテンツはデバイスのベゼルに追従できます。ConcentricRectangleはRoundedRectangularShapeではありません。そのため、コンテナになることはできません。それ自体がネストされているコンテナ(マップ上のカード)は、自身の子のために 結果として得られる 半径をRoundedRectangle(map radius − inset)として宣言します。matchedTransitionSource { $0.clipShape(…) }はRoundedRectangleのみを受け入れます — コンテンツの通常の半径を指定してください。- iOS 26 より前:最小半径の
RoundedRectangleにフォールバックします。RoundedRectangle上のcontainerShapeは引き続きコンパイルされます(古いInsettableShapeオーバーロード)。
再利用可能な角のスケール
コンテンツからコンテナを導出します。その逆ではありません。写真や小さなカードには1つの内側半径を選び、すべてのコンテナを 内側 + パディング にします。そうすると、パディングの内側にあるものはすべて正確に 内側 の半径になります。
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(_:)を宣言している。 - ネストされたコンテンツは、適切な
minimumを持つConcentricRectangle(またはヘルパー)を使用している。 - コンテナの半径 ≥ コンテンツの半径 + パディング。小さい半径の中に大きい半径が決してない。
- クリップ、背景、ガラス効果、ヒットテスト(
contentShape)はすべて同じシェイプを使用している。 - ボタン、バッジ、ピルにはカプセルを使用。携帯電話では画面端からの余分なマージン。
- iOS 18 へのフォールバックが適切に設定されている。
検証方法
macOS 26 以降で、高コントラスト塗りつぶしを使用してオフスクリーンレンダリングを行います(ImageRenderer、#available(iOS 26, macOS 26, *) をヘルパーのコピーで使用)。コンテナ、パディングの内側にあるコンテンツ、同じコンテンツを単独で表示したもの、そしてより大きなコンテナ内のフローティングカード。角が目に見えて中心を共有しているはずです。単独のものは最小値を示します。