Site icon AppTractor

Концентрические кнопки с использованием нового 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, поэтому по умолчанию у кнопки не будет скругления углов.

Обратите внимание, что тип реализует протокол Shape. В целом для работы самой реализации это не обязательно. Однако поскольку имя типа заканчивается на Shape и он будет доступен пользователям библиотеки, я предпочитаю соблюдать ожидаемую семантику и действительно делать его формой. Реализация path(in:) соответствует тому, как эта форма будет использоваться на следующих этапах, но именно она не отвечает за фактическое формирование границы кнопки.

Далее нам нужно написать «сердце» реализации — извлечение радиусов углов. Для этого мы воспользуемся 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 немного более открытым, чтобы разработчики могли создавать действительно пользовательские формы кнопок. Именно этот тип и должен использоваться для управления формой кнопок.

До встречи!

Источник

Exit mobile version