Переосмысление якорей: Границы.

28 апреля 2023

Мы переосмыслим якоря SwiftUI, чтобы лучше понимать, что они делают, начиная с якоря границ.

 

00:06

Сегодня мы начнем новую серию, в которой мы попытаемся переосмыслить якоря - не потому что нам это нужно, а потому что переосмысление якорей помогает нам понять, как они действительно работают, как их использовать и каковы их ограничения. Мы уже делали это некоторое время назад с эффектом согласованной геометрии .

 

00:43

Преимущество якорей заключается в том, что мы можем переработать их код в самом SwiftUI. Это отличается от того, когда мы пытались переосмыслить алгоритм макета SwiftUI, где нам пришлось выйти за пределы SwiftUI и создавать его с нуля. 

 

01:00

Якоря - это API, с которым мы не очень часто работаем, и поэтому мы решили их рассмотреть, написав их самостоятельно и, возможно, обнаружив новые примеры использования.


Якоря

 

01:18

Якорь помогает нам сделать две вещи. Во-первых, он передает геометрическое значение - например, прямоугольник, точку или размер - от одной точки в дереве вью к другой. Значение передается от исходного вью вверх по дереву вью через предпочтение и может использоваться в любой другой части дерева вью. Второе, что может сделать якорь, это преобразовать геометрическое значение в локальное пространство координат вью, где мы хотим его использовать.

 

01:41

Возможный пример использования следующий: предположим, что мы измерили точку глубоко внутри вложенного вью, и мы хотим выделить эту локацию. Якорь позволяет нам передать точку вверх по всему пути дерева вью. В другой части дерева мы можем преобразовать якорь в локальное пространство координат и поместить индикатор над измеренной точкой, не возвращаясь к месту, где точка была определена. Под капотом якорь, кажется, хранит свое геометрическое значение в глобальном пространстве координат, то есть относительно окна приложения или экрана симулятора.

 

02:42

Наш первый шаг в переосмыслении якорей - написать пример, используя существующее API. После этого мы можем изучить передаваемое значение, попробовать воссоздать API и заставить наши собственные якоря работать.


Пример

 

02:58

Предположим, мы хотим нарисовать эллипс вокруг текста "Hello, world" в стандартном вью SwiftUI. Мы легко можем это сделать, создав наложение с эллипсом, обводя эллипс и давая ему некоторый отрицательный отступ, чтобы он рисовался вокруг рамки исходного текста:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .overlay {
                    Ellipse()
                        .stroke(Color.red, lineWidth: 1)
                        .padding(-10)
                }
        }
        .padding()
    }
}

 


 

03:38

Возможно, у нас есть веская причина, чтобы нарисовать этот эллипс в другой точке иерархии вью. Например, мы можем хотеть, чтобы он всегда находился поверх других вью. Или мы можем захотеть создать индикатор эллипса один раз и анимировать его от одного вью к другому. Но если мы просто переместим наложение на VStack, очевидно, что он будет нарисован вокруг всего вью:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
        .overlay {
            Ellipse()
                .stroke(Color.red, lineWidth: 1)
                .padding(-10)
        }
    }
}

 

 

04:13

Здесь нам может помочь якорь. Сначала мы создаем ключ предпочтений, где в качестве значения Anchor<CGRect>?. В методе reduce ключа мы берем первое не-nill значение из иерархии вью:

struct HighlightKey: PreferenceKey {
    static var defaultValue: Anchor<CGRect>?

    static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
        value = value ?? nextValue()
    }
}

 

04:38

Мы создаем и передаем якорь, вызывая модификатор anchorPreference. Эта функция принимает наш ключ, значение якоря и функцию трансформации. Для значения мы указываем описание того, что мы хотим измерить. Используя автозаполнение, мы легко можем выбрать из списка предопределенных значений, таких как .bounds, который является CGRect, или .leading, который является CGPoint. Мы выбираем границы (bounds) вью:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
    }
}

 

05:15

Функция трансформации преобразует значение якоря в значение для ключа предпочтений. В этом случае мы получаем не опциональный  Anchor<CGRect>, но нам нужен опциональный якорь того же типа, чтобы мы могли вернуть $0, чтобы Swift выполнил преобразование.

 

05:48

Теперь мы можем получить доступ к якорю, вызвав onPreferenceChange с тем же ключом предпочтений и замыканием действия. В замыкании мы можем вывести якорь на печать, чтобы увидеть, что у нас есть, но это не говорит нам более того, что мы имеем дело с опциональным якорем типа CGRect. Однако мы можем получить немного больше информации, если выведем значение с помощью dump:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .onPreferenceChange(HighlightKey.self, perform: {
            dump($0)
        })
    }
}

 

06:17

Выводим следующее в консоль и видим, что якорь содержит прямоугольник, начало которого измеряется от верхнего левого края нашего приложения, что означает, что это CGRect в глобальной системе координат:

▿ Optional(SwiftUI.Anchor<__C.CGRect>(box: SwiftUI.(unknown context at $1cce82530).AnchorValueBox<SwiftUI.UnitRect>))
  ▿ some: SwiftUI.Anchor<__C.CGRect>
    ▿ box: SwiftUI.(unknown context at $1cce82530).AnchorValueBox<SwiftUI.UnitRect> #0
      - super: SwiftUI.AnchorValueBoxBase<__C.CGRect>
      ▿ value: (412.5, 241.0, 75.5, 16.0)
        ▿ origin: (412.5, 241.0)
          - x: 412.5
          - y: 241.0
        ▿ size: (75.5, 16.0)
          - width: 75.5
          - height: 16.0

 

06:41

Давайте попробуем нарисовать наш овал вокруг этого прямоугольника. Вместо того, чтобы вызывать onPreferenceChange, сохранять значение в свойстве состояния и затем использовать его в наложении, мы можем переключиться на overlayPreferenceValue, чтобы сразу создать наложение с переданным якорем:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            Ellipse()
                .stroke(Color.red, lineWidth: 2)
                .padding(-10)
            }
        }
    }
}



07:50

Таким образом мы рисуем эллипс вокруг всего VStack, т.к. еще не используем переданное в замыкание значение якоря. Чтобы использовать якорь, нам сначала нужно разрешить его с помощью программы чтения конфигурации.

 

08:23

Но мы также должны убедиться, что он не-nil, для этого мы сначала пытаемся извлечь значение:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {

            }
        }
    }
}

 

08:58


Далее, мы добавляем ридер конфигарции и используем его прокси, чтобы разрешить якорь. Это дает нам CGRect, размер которого мы можем использовать для рамки вокруг нашего эллипса:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                }
            }
        }
    }
}

 

 

10:05

Эллипс выглядит точным по размеру, но находится не в нужном месте.


10:12

Когда мы переместили якорь на иконку глобуса, эллипс принял размер иконки (плюс отрицательный отступ). Это подтверждает, что размер определяется границами исходного значения якоря.

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
            Text("Hello, world!")
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                }
            }
        }
    }
}

 

 

10:38

После того, как мы вернули якорь на текстовое вью и сместили эллипс с помощью координат X и Y начала разрешенного прямоугольника, эллипс нарисован там, где мы хотели.

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
    }
}

 

 

10:59

Координаты рамки, которые мы получаем при разрешении якоря, определяются в локальной системе координат геометрического ридера, которая равна системе координат наложения. Вот поэтому, чтобы переместить эллипс в нужное место, мы можем сделать смещение с помощью начала координат рамки.


11:30

Мы сделали все это, используя якоря SwiftUI. Теперь вопрос - как мы можем реализовать это сами?


Воссоздание API

 

11:41 

Нам нужна структура якоря, которая должна быть универсальной для любого типа значения. В нашем примере этим типом является CGRect, но в зависимости от источника якоря, это также может быть CGPoint или CGSize:

public struct MyAnchor<Value> {

}

 

11:58 

Нам также нужна наша собственная версия метода anchorPreference. Этот метод универсальный по типу значения якоря и типу ключа предпочтения. Он принимает ключ и исходник для якоря в качестве своих первых двух параметров. В версии SwiftUI, исходный параметр имеет тип Anchor<Value>.Source, поэтому нам также нужно добавить тип Source для нашего якоря:

public struct MyAnchor<Value> {
    public struct Source {
    }
}

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, ...) -> some View {

    }
}

 

12:55

Третий параметр - это функция transform, которая превращает якорь в значение, которое может быть передано с ключом предпочтения:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {

    }
}

 

13:32 

Исходник якоря описывает то, что мы хотим измерить - в нашем примере мы используем его, чтобы запросить границы исходника. Это только описание значения, которое мы измерим, т.к. Нам неизвестны фактические границы в момент инициализации якоря:

public struct MyAnchor<Value> {
    public struct Source {
        var measure: (CGRect) -> Value
    }
}

extension MyAnchor<CGRect>.Source {
    public static var bounds: Self {

    }
}

 

14:34 

Чтобы выполнить измерения, структуре источника нужно получить глобальную рамку. С этой рамкой мы можем измерять такие вещи, как границы и точки верхнего-левого угла. Мы добавляем свойство measure - функцию, которая принимает CGRect в глобальной системе координат и возвращает значение якоря:

extension MyAnchor<CGRect>.Source {
    public static var bounds: Self {
        Self(measure: { $0 })
    }
}

 

15:47 

Значение, возвращаемое функцией measure, также определяется в глобальной системе координат. Мы должны сохранить это значение в якоре:

public struct MyAnchor<Value> {
    var value: Value

    public struct Source {
        var measure: (CGRect) -> Value
    }
}

 

Реализация

 

16:39 

Чтобы реализовать myAnchorPreference, нам нужно измерить вью, на котором мы установлены, передать прямоугольник в Source и затем превратить это в якорь и передать его наверх.


17:02 

Чтобы измерить вью, нам нужен ридер конфигурации. Мы получаем глобальную рамку вью из прокси конфигурации:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)


        })
    }
}

 

17:35 

Мы передаем данную рамку в функцию measure для value, что дает нам геометрическое значение для якоря:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)
            let anchorValue = value.measure(frame)
            let anchor = MyAnchor(value: anchorValue)

        })
    }
}

 

18:22 

Мы вызываем функцию transform, чтобы превратить этот якорь в значение предпочтения, и, наконец, используя key,  мы передаем якорь вверх:

extension View {
    func myAnchorPreference<Value, Key: PreferenceKey>(key: Key.Type, value: MyAnchor<Value>.Source, transform: @escaping (MyAnchor<Value>) -> Key.Value) -> some View {
        overlay(GeometryReader { proxy in
            let frame = proxy.frame(in: .global)
            let anchorValue = value.measure(frame)
            let anchor = MyAnchor(value: anchorValue)
            Color.clear.preference(key: key, value: transform(anchor))
        })
    }
}

 

19:22

Чтобы сравнить наши результаты с реализацией SwiftUI, мы воспроизводим пример, используя нашу собственную API. Для этого нам нужен другой ключ предпочтения, потому что мы хотим передавать MyAnchor вместо Anchor:

struct MyHighlightKey: PreferenceKey {
    static var defaultValue: MyAnchor<CGRect>?

    static func reduce(value: inout MyAnchor<CGRect>?, nextValue: () -> MyAnchor<CGRect>?) {
        value = value ?? nextValue()
    }
}

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
                .myAnchorPreference(key: MyHighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
        .overlayPreferenceValue(MyHighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                }
            }
        }
    }
}

 

 

20:53 

Нам все еще нужен сабскрипт на прокси конфигурации для разрешения нашего якоря. Этот сабскрипт принимает MyAnchor<Value> и должен возвращать Value в локальной системе координат:

extension GeometryProxy {
    subscript<Value>(_ anchor: MyAnchor<Value>) -> Value {

    }
}

 

21:41 

Внутри прокси конфигурации у нас есть доступ к глобальной рамке вью, и мы должны каким-то образом вычислить локальное значение якоря из этой рамки:

extension GeometryProxy {
    subscript<Value>(_ anchor: MyAnchor<Value>) -> Value {
        let s = frame(in: .global)
        let o = anchor.value

    }
}

 

22:40

 s  - это рамка текущего вью (или self) в глобальной системе координат, а  o  - это другая рамка в той же системе. Предполагая, что значение якоря - это CGRect, мы можем сместить рамку o , чтобы преобразовать ее в локальную систему координат s:

extension GeometryProxy {
    subscript(_ anchor: MyAnchor<CGRect>) -> CGRect {
        let s = frame(in: .global)
        let o = anchor.value
        return o.offsetBy(dx: -s.origin.x, dy: -s.origin.y)
    }
}

 

 

Сравнение Результатов

 

24:32

Посмотрим, работает ли это. Мы даем заниженную непрозрачность слою с помощью нашей собственной реализации. Теперь мы видим оба, красный и синий, эллипса в одном и том же месте:


 

25:21 

А когда мы перемещаем якоря к иконке глобуса, эллипсы отображаются вокруг иконки:

 

25:41

Мы добавляем булевое свойство состояния и переключатель, чтобы легко переключаться между реализацией SwiftUI и нашей собственной. Булевое значение определяет непрозрачность для каждого наложения:

struct ContentView: View {
    @State private var myVisibility = true

    var body: some View {
        VStack {
            Toggle("Show MyAnchor implementation", isOn: $myVisibility)
                .padding(.bottom, 30)
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
                .anchorPreference(key: HighlightKey.self, value: .bounds, transform: { $0 })
                .myAnchorPreference(key: MyHighlightKey.self, value: .bounds, transform: { $0 })
        }
        .padding()
        .overlayPreferenceValue(HighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                        .opacity(myVisibility ? 0 : 1)
                }
            }
        }
        .overlayPreferenceValue(MyHighlightKey.self) { value in
            if let value {
                GeometryReader { proxy in
                    let frame = proxy[value]
                    Ellipse()
                        .stroke(Color.red, lineWidth: 2)
                        .padding(-10)
                        .frame(width: frame.width, height: frame.height)
                        .offset(x: frame.origin.x, y: frame.origin.y)
                        .opacity(myVisibility ? 1 : 0)
                }
            }
        }
    }
}

 

27:16 

Мы не видим разницы при переключении между двумя реализациями, что означает, что мы на правильном пути.

  
27:21

Осталось еще много работы. Чтобы добавить поддержку различных типов значений, кроме CGRect, нам определенно нужно изменить сабскрипт на GeometryProxy. Мы также должны обдумать, как может быть трансформирован вью и каким образом это повлияет на разрешение нашей привязки. Давайте продолжим на следующей неделе.

 

Оригинал статьи

Содержание