Teabyte

App development for Apple platforms and Swift

Concentric Buttons with OS 27's SwiftUI APIs

SwiftUI for iOS, iPadOS, and macOS 27 (I'll shorten this to *OS 27 from here on) is bringing a lot of new additions. One particular addition that is very interesting to me is the ability to read out concentricCornerRadii on a GeometryProxy. I smelled that this might be a possibility to create a truly concentric button shape. Since the introduction of corner concentricity, I was eagerly waiting for a dedicated ButtonBorderShape which respects the corners. Until now, every update has left me disappointed. Even with *OS 27, SwiftUI still misses a first-class shape, but now we have the opportunity to create our own solution that merges into the standard SwiftUI APIs, hiding our custom implementation as well as possible.

Reading Out the Concentric Corner Radii

In order to read out the concentricCornerRadii property of GeometryProxy, we simply use the onGeometryChange method and access the property right away:

.onGeometryChange(for: RectangleCornerRadii?.self) { proxy in
    proxy.concentricCornerRadii
} action: { corners in
    // do something with the corner radii
}

Note that concentricCornerRadii is optional, it only resolves to a value when an ancestor view defines a shape via containerShape(_:) (e.g. .containerShape(.rect(cornerRadius: 70))), or when the view sits close enough to one of the device's edges - in that case, the system computes the radii concentrically to the display's own corners automatically, without any containerShape(_:) needed. Outside of these two cases, it returns nil and we fall back to the minimum, which we account for below.

Now that we know how to access the corner radii, we can use this to modify a button's border shape to make it behave as if it had a concentric corner shape.

Improve a Button's ButtonBorderShape

Unfortunately ButtonBorderShape is a "closed" type. It is not possible to create our own instances of it. This makes it a little harder to create a nice API for our extension, but not impossible.

First we define a type that transports the information about the custom border shape. I will use @available annotations throughout the code samples to emphasise that this is an OS 27 API, utilising the new anyAppleOS annotation, which makes writing them a lot less boilerplate-heavy.

@available(anyAppleOS 27, *)
struct ConcentricButtonBorderShape: Sendable, Shape {
 
    static let concentric = Self()
    static func concentric(minimum: CGFloat) -> Self { Self(minimum: minimum) }
  
    var minimum: CGFloat = 0
 
    func path(in rect: CGRect) -> Path {
        ConcentricRectangle(
            corners: .concentric(minimum: .fixed(minimum)),
            isUniform: true
        )
        .path(in: rect)
    }
}

The shape also sets a minimum corner radius. This takes effect in case the button is too far away from a corner to calculate its corner radius. By setting it to 0 here for demonstration purposes, the button will have no corner radius by default.

Please note that the type implements the Shape protocol. This is not necessary for the implementation to work in general. But since the type is named with a Shape suffix and will be publicly visible to consumers, I tend to be a good citizen and make sure the expectations implied by that name are met. The actual path(in:) implementation reflects how the shape is used in the next steps, but it is not what actually shapes the button itself.

Next up we need to write the "heart" of the implementation, the extraction of the corner radii. We are going to utilise onGeometryChange for that.

@available(anyAppleOS 27, *)
extension View {
    func onConcentricCornerRadiiChange(
        minimum: CGFloat,
        action: @escaping (_ newValue: RectangleCornerRadii) -> Void
    ) -> some View {
        onGeometryChange(for: RectangleCornerRadii.self) { proxy in
            guard let radii = proxy.concentricCornerRadii else {
                return RectangleCornerRadii(
                    topLeading: minimum,
                    bottomLeading: minimum,
                    bottomTrailing: minimum,
                    topTrailing: minimum
                )
            }
            return radii
        } action: {
            action($0)
        }
    }
}

In case no concentricCornerRadii is available, we return a default one using the minimum parameter for all corners. Hooking that up to an action, we allow consumers to read that value and react to changes in it. We deliberately avoid using a Binding here because it would convey the wrong semantic meaning - this is a one-way reporting of a value, whereas a Binding implies a two-way connection.

By reading out the values, we can now also write our own view modifier that hooks up our custom logic into the regular button border shape "flow".

@available(anyAppleOS 27, *)
private struct ConcentricButtonBorder: ViewModifier {
    let shape: ConcentricButtonBorderShape
    @State private var radii: RectangleCornerRadii
 
    init(shape: ConcentricButtonBorderShape) {
        self.shape = shape
        radii = RectangleCornerRadii(shape.minimum)
    }
 
    func body(content: Content) -> some View {
        content
            .buttonBorderShape(.roundedRectangle(radius: radii.uniform))
            .onConcentricCornerRadiiChange(minimum: shape.minimum) { radii = $0 }
    }
}

We piggy-back on the existing roundedRectangle button border shape and continuously update it with the uniform value of the concentric corner radius we read. There is one drawback to this: we can't set each edge of the button's shape individually because we're still constrained by what the regular ButtonBorderShape type supports. I think this is negligible, I did not yet encounter buttons with non-uniform corners in any application I was developing.

Now to the final part, the consumer-facing API. Typically a consumer sets the shape of a button by utilising the buttonBorderShape(_:) modifier on a View. We can utilise that and overload that method with our own types. From a consumer's perspective, it looks like a native method, but in fact they're calling our custom one. This follows the principles I explain in The Anatomy of a Reusable SwiftUI View to write Apple-native-feeling APIs.

@available(anyAppleOS 27, *)
extension View {
    func buttonBorderShape(_ shape: ConcentricButtonBorderShape) -> some View {
        modifier(ConcentricButtonBorder(shape: shape))
    }
}

Now finished, we can use our small extension like any other button border shape, and from a consumer's perspective it feels like a first-party API. It also works naturally with all of the other available button modifiers.

Note that the outer container needs to declare its shape via containerShape(_:) - without it, concentricCornerRadii has nothing to read and the button falls back to minimum.

VStack {
    Button("Concentric Button") {
        // my action
    }
    .buttonStyle(.glassProminent)
    .buttonBorderShape(.concentric(minimum: 0))
    .buttonSizing(.flexible)
}
.containerShape(.rect(cornerRadius: 70)) // required for concentricCornerRadii to resolve

Conclusion

With this small extension, we made it possible to write concentric button border shapes that feel native and not intrusive. I still hope that Apple will release a native .concentric button border shape, or at least open up ButtonBorderShape a little bit more to make it possible to write truly custom shapes for buttons, since that's the type that should be used to shape buttons.

Please reach out to me if you find any mistakes, or have questions or suggestions. You can find ways to contact me on my About page.

See you next time! 👋