Разработка
Концентрические кнопки с использованием нового API SwiftUI в iOS 27
С помощью этого небольшого расширения мы получили возможность создавать концентрические формы границы кнопок, которые ощущаются нативно и не требуют громоздкой дополнительной логики.
SwiftUI для iOS, iPadOS и macOS 27 (далее я буду сокращать до *OS 27) приносит множество новых дополнений. Одно из таких дополнений, которое меня особенно заинтересовало, — это возможность считывать значение свойства concentricCornerRadii для GeometryProxy. Я чувствовал, что это может стать способом создания действительно концентрической формы кнопки. С момента появления концентричности углов я с нетерпением ждал появления специального ButtonBorderShape, учитывающего углы. До сих пор каждое обновление меня разочаровывало. Даже в *OS 27 SwiftUI по-прежнему не хватает полноценной формы, но теперь у нас есть возможность создать собственное решение, которое интегрируется в стандартные API SwiftUI, максимально скрывая нашу собственную реализацию.
Считывание значения свойства concentricCornerRadii
Чтобы считать значение свойства concentricCornerRadii объекта GeometryProxy, мы просто используем метод onGeometryChange и сразу же получаем доступ к свойству:
.onGeometryChange(for: RectangleCornerRadii?.self) { proxy in
proxy.concentricCornerRadii
} action: { corners in
// do something with the corner radii
}
Обратите внимание, что concentricCornerRadii — необязательное значение. Оно определяется только в двух случаях. Первый — когда одно из родительских представлений задаёт форму через containerShape(_:), например: .containerShape(.rect(cornerRadius: 70)) . Второй — когда представление находится достаточно близко к одному из краёв устройства. В этом случае система автоматически рассчитывает радиусы концентрически относительно собственных углов дисплея, и использовать containerShape(_:) не требуется. Во всех остальных случаях свойство возвращает nil, и тогда мы используем значение minimum в качестве запасного варианта. Ниже это будет учтено.
Теперь, когда мы знаем, как получить радиусы углов, можно использовать их для изменения формы границы кнопки, чтобы она вела себя так, будто у неё есть концентрическая форма углов.
Улучшаем ButtonBorderShape
К сожалению, ButtonBorderShape — «закрытый» тип. Создавать собственные экземпляры этого типа нельзя. Из-за этого сделать удобный программный интерфейс для нашего расширения немного сложнее, но всё ещё возможно.
Для начала определим тип, который будет передавать информацию о пользовательской форме границы. Во всех примерах кода я буду использовать аннотации @available, чтобы подчеркнуть, что это программный интерфейс OS 27. Здесь применяется новая аннотация anyAppleOS, которая позволяет писать такие проверки заметно компактнее и без лишнего шаблонного кода.
@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)
}
}
Форма также задаёт минимальный радиус скругления. Он используется в том случае, если кнопка находится слишком далеко от угла, чтобы система могла вычислить для неё концентрический радиус. В демонстрационном примере мы устанавливаем это значение равным 0, поэтому по умолчанию у кнопки не будет скругления углов.
Далее нам нужно написать «сердце» реализации — извлечение радиусов углов. Для этого мы воспользуемся onGeometryChange.
@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)
}
}
}
Если значение concentricCornerRadii недоступно, мы возвращаем значение по умолчанию, используя параметр minimum для всех углов. Передавая полученное значение в обработчик, мы позволяем пользователям читать его и реагировать на изменения. Мы намеренно не используем здесь Binding, потому что он передавал бы неверный смысл. В данном случае значение только сообщается в одном направлении, тогда как Binding подразумевает двустороннюю связь.
Теперь, когда мы умеем получать радиусы углов, можно написать собственный модификатор представления, который встроит нашу логику в стандартный механизм работы с формой границы кнопки.
@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 }
}
}
Мы используем существующую форму границы кнопки roundedRectangle и постоянно обновляем её единым значением концентрического радиуса углов, которое считываем. У этого подхода есть один недостаток: мы не можем задавать радиус для каждого угла кнопки отдельно, поскольку всё ещё ограничены возможностями стандартного типа ButtonBorderShape. На мой взгляд, это несущественно — по крайней мере, мне пока не встречались приложения, где у кнопок действительно требовались бы разные радиусы для разных углов.
Теперь переходим к финальной части — программному интерфейсу для пользователя. Обычно форму границы кнопки задают с помощью модификатора buttonBorderShape(_:) у View. Мы можем воспользоваться этим и перегрузить метод, добавив поддержку собственных типов. С точки зрения пользователя это будет выглядеть как обычный встроенный метод SwiftUI, хотя на самом деле вызывается наша пользовательская реализация. Такой подход соответствует принципам, которые я описываю в статье The Anatomy of a Reusable SwiftUI View: программные интерфейсы должны ощущаться так, будто они изначально были частью SwiftUI.
@available(anyAppleOS 27, *)
extension View {
func buttonBorderShape(_ shape: ConcentricButtonBorderShape) -> some View {
modifier(ConcentricButtonBorder(shape: shape))
}
}
Теперь расширение полностью готово, и мы можем использовать его так же, как любую другую форму границы кнопки. С точки зрения пользователя оно выглядит как встроенный API SwiftUI и естественно сочетается со всеми остальными доступными модификаторами кнопок.
Обратите внимание, что внешний контейнер должен задавать свою форму с помощью containerShape(_:). Без этого concentricCornerRadii не сможет получить значение радиусов, и кнопка будет использовать значение minimum в качестве запасного варианта.
VStack {
Button("Concentric Button") {
// my action
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.concentric(minimum: 0))
.buttonSizing(.flexible)
}
.containerShape(.rect(cornerRadius: 70)) // required for concentricCornerRadii to resolve
Заключение
С помощью этого небольшого расширения мы получили возможность создавать концентрические формы границы кнопок, которые ощущаются нативно и не требуют громоздкой дополнительной логики. Я всё ещё надеюсь, что Apple добавит встроенную форму границы кнопки .concentric или хотя бы сделает ButtonBorderShape немного более открытым, чтобы разработчики могли создавать действительно пользовательские формы кнопок. Именно этот тип и должен использоваться для управления формой кнопок.
До встречи!
-
Интегрированные среды разработки4 недели назадАналоги Cursor для разработчиков: что выбрать для работы с кодом
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.29
-
Разработка4 недели назад50 вопросов о System Design, ответы на которые должен знать каждый Senior iOS-разработчик: часть 1
-
Разработка4 недели назадSystem Design: как бы вы отправили 1 миллион уведомлений?
