SwiftUI продолжает быстро развиваться, и в этом году мы видим огромное количество улучшений в прокрутке, интересные новые эффекты SF Symbols, продвинутую поддержку шейдеров Metal и многое другое.

Некоторые обновления в этом релизе из числа тех, которые автор предлагал лично. Среди них - добавление шейдеров Metal, улучшение работы Color с Codable, добавление замыкания по завершении для анимаций, которое дает нам возможность анимировать градиенты. Теперь мы ещё можем скруглять углы прямоугольника по своему усмотрению. Только благодаря первой бета-версии автор закрыл как минимум дюжину своих предложений!

Набор обновлений для ScrollView

Привязка ScrollView к страницам и дочерним представлениям

В SwiftUI компонент ScrollView по умолчанию двигается плавно, но с использованием модификаторов scrollTargetLayout() и scrollTargetBehavior() мы можем сделать так, чтобы он автоматически "привязывался" к определенным дочерним представлениям или целым страницам.

Поместим в качестве примера 10 скругленных прямоугольников в горизонтальный ScrollView, каждый из которых будет целевым при скроллинге. Поскольку .scrollTargetBehavior() установлен как .viewAligned, SwiftUI автоматически будет "привязываться" к каждому из скругленных прямоугольников:

struct ContentView: View {
    var body: some View {
        ScrollView(.horizontal) {
            LazyHStack {
                ForEach(0..<10) { index in
                    RoundedRectangle(cornerRadius: 25)
                        .fill(Color(hue: Double(index) / 10, saturation: 1, brightness: 1).gradient)
                        .frame(width: 300, height: 100)
                }
            }
            .scrollTargetLayout()
        }
        .scrollTargetBehavior(.viewAligned)
        .safeAreaPadding(.horizontal, 40)
    }
}

При использовании модификатора scrollTargetLayout(), все элементы внутри контейнера становятся целями прокрутки, к которым ScrollView может "привязываться". Если необходимо, чтобы прокрутка "привязывалась" только к определенным дочерним представлениям, то вместо scrollTargetLayout() нужно использовать модификатор scrollTarget(), который используется для отдельных элементов.

Есть и альтернативный режим привязки прокрутки — .paging. Этот режим позволяет ScrollView перемещаться ровно на ширину или высоту экрана, в зависимости от направления прокрутки:

ScrollView {
    ForEach(0..<50) { index in
        Text("Item \(index)")
            .font(.largeTitle)
            .frame(maxWidth: .infinity)
            .frame(height: 200)
            .background(.blue)
            .foregroundStyle(.white)
            .clipShape(.rect(cornerRadius: 20))
    }
}
.scrollTargetBehavior(.paging)

Здесь создается ScrollView, содержащий 50 элементов типа Text, каждый из которых заполняет полную ширину экрана. С помощью модификатора .scrollTargetBehavior(.paging) обеспечивается прокрутка ровно на один экран при каждом движении:

Отображение контента за пределами ScrollView

Компонент ScrollView в SwiftUI автоматически обрезает свое содержимое, чтобы элементы прокрутки всегда оставались полностью внутри области контейнера. Однако, использую модификатор scrollClipDisabled(), стандартное поведение можно изменить, отключив обрезку контента.

Важно: Изменение поведения не влияет на обработку касаний пользователя, т.е. область касания остается в пределах ScrollView. По умолчанию, когда элементы находятся внутри ScrollView, все касания по этим элементам обрабатываются как взаимодействие с ними. Однако, если элементы выходят за границы ScrollView из-за использования scrollClipDisabled(), любые касания по "выпавшим" элементам не будут регистрироваться как взаимодействие с ними. Вместо этого, эти касания будут регистрироваться как взаимодействие с тем, что находится под этими элементами.

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

Давайте рассмотрим c VStack, в котором сверху и снизу расположены статичные текстовые элементы, а между ними - область с прокруткой. При начале прокрутки элементы этой области будут располагаться прямо под верхним текстом, но в процессе прокрутки они смогут выходить за его границы:

VStack {
    Text("Fixed at the top")
        .frame(maxWidth: .infinity)
        .frame(height: 100)
        .background(.green)
        .foregroundStyle(.white)

    ScrollView {
        ForEach(0..<5) { _ in
            Text("Scrolling")
                .frame(maxWidth: .infinity)
                .frame(height: 200)
                .background(.blue)
                .foregroundStyle(.white)
        }
    }
    .scrollClipDisabled()

    Text("Fixed at the bottom")
        .frame(maxWidth: .infinity)
        .frame(height: 100)
        .background(.green)
        .foregroundStyle(.white)
}

При работе с модификатором scrollClipDisabled() важно помнить о двух вещах:

  1. Вы можете задать свою форму обрезки, чтобы контролировать, насколько далеко элементы будут выходить за границы области прокрутки. Например, если после применения отступов padding() вы добавите форму обрезки в виде прямоугольника clipShape(.rect), элементы не будут "выпадать" за пределы области бесконечно.

  2. Поскольку элементы прокрутки теперь могут перекрывать окружающие их элементы, вам может потребоваться использовать модификатор zIndex() для корректировки их вертикального расположения. Например, если у других элементов используется стандартный Z-индекс, то применение zIndex(1) к ScrollView заставит его дочерние элементы отрисовываться поверх других элементов.

Отображение контента в ScrollView с конца

В SwiftUI компонент ScrollView автоматически начинает прокрутку сверху. Однако если вы хотите создать интерфейс, аналогичный приложению "Сообщения" от Apple, вы можете настроить ScrollView так, чтобы прокрутка начиналась снизу. Для этого используется модификатор scrollPosition() с начальной привязкой к нижней части (.bottom).

Ниже представлен пример, где в ScrollView показаны 50 текстовых элементов. Здесь указано, что прокрутка должна начинаться снизу, а не сверху:

ScrollView {
    ForEach(0..<50) { index in
        Text("Item \(index)")
            .frame(maxWidth: .infinity)
            .padding()
            .background(.blue)
            .clipShape(.rect(cornerRadius: 25))
    }
}
.scrollPosition(initialAnchor: .bottom)

Если ваш пользовательский интерфейс каким-то образом изменяется без прокрутки пользователя - например, если появляется клавиатура или вы меняете размер области прокрутки, то позиция прокрутки останется прикрепленной к нижней части. Однако, если пользователь самостоятельно меняет позицию прокрутки, она будет свободно прокручиваться, как обычно.

Совет: параметр initialAnchor принимает любую точку типа UnitPoint, поэтому вы можете использовать .trailing для начала горизонтальной прокрутки с правого края или любое точное значение, которое вам нужно для вашего интерфейса.

Установка отступов для содержимого или индикаторов прокрутки в ScrollView

По умолчанию компонент ScrollView в SwiftUI позволяет своему содержимому заполнять все доступное пространство, а индикаторы прокрутки аккуратно располагаются на краю экрана. Однако, с помощью модификатора contentMargins() можно установить отступы для содержимого или полос прокрутки - насколько угодно большие и по любым краям.

В качестве примера, рассмотрим код, который вносит отступ в содержимое области прокрутки на 50 поинтов с каждой стороны, не меняя при этом положение индикаторов прокрутки:

ScrollView {
    ForEach(0..<50) { index in
        Text("Item \(index)")
            .frame(maxWidth: .infinity)
            .foregroundStyle(.white)
            .background(.blue)
    }
}
.contentMargins(50, for: .scrollContent)

Таким же образом вы можете отрегулировать величину отступа для индикаторов прокрутки ScrollView, либо самостоятельно, либо вместе с регулировкой величины отступа содержимого.

Как и ранее, вы можете настроить все стороны сразу или выбрать только те, которые вам нужны. Так, в следующем примере добавляется отступ в 100 поинтов сверху для индикатора прокрутки, в то время как остальные стороны остаются без изменений:

ScrollView {
    ForEach(0..<50) { index in
        Text("Item \(index)")
            .frame(maxWidth: .infinity)
            .background(.blue)
            .foregroundStyle(.white)
    }
}
.contentMargins(.top, 100, for: .scrollIndicators)

Использование отступов для содержимого (content margins) вместо простого padding (внутренний отступ) позволяет вашему содержимому прокручиваться от края до края при взаимодействии пользователя с ним, при этом добавляя немного дополнительного пространства для прокрутки - это гораздо более удачная опция, чем простой padding.

В контексте SwiftUI, padding относится к внутреннему отступу вокруг содержимого представления, что влияет на его размер и положение внутри его родительского представления. С другой стороны, отступы содержимого или content margins относятся к пространству, которое оставляется вокруг содержимого области прокрутки. Это пространство позволяет содержимому прокручиваться "от края до края", давая дополнительное пространство для взаимодействия с прокруткой.

Таким образом, использование отступов для содержимого может быть более предпочтительным, если вы хотите, чтобы ваше содержимое прокручивалось за края экрана, в то время как простой padding ограничит ваше содержимое внутри заданных границ.

Прокрутка представлений с кастомным переходом

В SwiftUI ScrollView располагает все свои дочерние элементы в контейнере, который плавно прокручивается вертикально или горизонтально. Однако, если мы применим к дочерним представлениям модификатор scrollTransition(), то сможем настроить способ, которым эти представления появляются на экране и исчезают с него.

Этот модификатор принимает замыкание, которое должно принимать как минимум два параметра: контент для управления (одно дочернее представление в области прокрутки) и фазу перехода прокрутки. Фаза может иметь одно из трёх значений:

  1. Фаза .identity, что означает, что представление видимо на экране.

  2. Фаза .topLeading, в которой представление собирается появиться с верхней или левой границы в зависимости от направления прокрутки ScrollView.

  3. Фаза .bottomTrailing, которая является аналогом .topLeading для нижней/правой границы.

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

ScrollView {
    ForEach(0..<10) { index in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .scrollTransition { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0)
                    .scaleEffect(phase.isIdentity ? 1 : 0.75)
                    .blur(radius: phase.isIdentity ? 0 : 10)
            }
            .padding(.horizontal)
    }
}

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

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

ScrollView {
    ForEach(0..<10) { index in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .scrollTransition(.animated.threshold(.visible(0.9))) { content, phase in
                content
                    .opacity(phase.isIdentity ? 1 : 0)
                    .scaleEffect(phase.isIdentity ? 1 : 0.75)
                    .blur(radius: phase.isIdentity ? 0 : 10)
            }
            .padding(.horizontal)
    }
}

В этом примере мы используем метод .animated.threshold(.visible(0.9)), который задаёт порог видимости в 90%. Это означает, что при прокрутке элементы будут появляться или исчезать, когда они станут видны на 90%.

Вы можете получить доступ к значению фазы перехода, которое будет равно -1 для представлений в верхней или левой фазе (в зависимости от направления прокрутки), 1 для представлений в нижней или правой фазе, и 0 для всех остальных представлений.

Например, следующий код аккуратно изменяет оттенок каждой прокручивающейся фигуры, сочетая значение phase.value с модификатором hueRotation():

ScrollView {
    ForEach(0..<10) { index in
        RoundedRectangle(cornerRadius: 25)
            .fill(.blue)
            .frame(height: 80)
            .shadow(radius: 3)
            .scrollTransition { content, phase in
                content
                    .hueRotation(.degrees(45 * phase.value))
            }
            .padding(.horizontal)
    }
}

Мигание индикаторами прокрутки ScrollView или List

В SwiftUI появился модификатор scrollIndicatorsFlash(), который позволяет контролировать моменты, когда индикаторы прокрутки ScrollView или List "мигают" (кратковременно отображаются и исчезают). Этот эффект может быть полезен для информирования пользователей о том, что некоторые данные в прокручиваемой области изменились.

Модификатор scrollIndicatorsFlash() может быть использован в двух вариантах:

  1. Индикаторы мигают при первоначальном отображении прокручиваемого представления (ScrollView или List). Это можно использовать для того, чтобы намекнуть пользователям, что в представлении есть дополнительные данные, которые могут быть просмотрены путем прокрутки.

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

В следующем примере индикаторы прокрутки начнут мигать при первом отображении ScrollView:

ScrollView {
    ForEach(0..<50) { index in
        Text("Item \(index)")
            .frame(maxWidth: .infinity)
    }
}
.scrollIndicatorsFlash(onAppear: true)

В дополнение к предыдущим вариантам использования, модификатор scrollIndicatorsFlash() может также принимать конкретное значение, которое определяет, когда должны мигать индикаторы прокрутки. Это значение может быть любого типа, который удовлетворяет протоколу Equatable, и при каждом его изменении SwiftUI будет вызывать "мигание" индикаторов.

Так, например, вы можете увеличивать целое число, генерировать случайный UUID или просто переключать булевое значение между true и false. В приведенном примере используется булево состояние exampleState. Когда пользователь нажимает на кнопку "Flash!", значение exampleState переключается (между true и false), что вызывает мигание индикаторов прокрутки в ScrollView:

struct ContentView: View {
    @State private var exampleState = false

    var body: some View {
        VStack {
            ScrollView {
                ForEach(0..<50) { index in
                    Text("Item \(index)")
                        .frame(maxWidth: .infinity)
                        .background(.blue)
                        .foregroundStyle(.white)
                }
            }
            .scrollIndicatorsFlash(trigger: exampleState)

            Button("Flash!") {
                exampleState.toggle()
            }
        }
    }
}

Всё рассмотренное выше так же применимо к типу List

Вертикальная прокрутка страниц

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

Для начала, вот простой пример кода:

TabView {
    Text("First")
        .navigationTitle("First Title")
    Text("Second")
        .navigationTitle("Second Title")
    Text("Third")
        .navigationTitle("Third Title")
}
.tabViewStyle(.verticalPage)

Заголовки вкладок задаются с помощью модификатора navigationTitle(). При прокрутке этих вкладок, заголовки автоматически используются watchOS.

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

Так, в этом примере кода есть две обычные вкладки, за которыми следует прокручиваемый список:

TabView {
    Text("First")
        .navigationTitle("First Title")
    Text("Second")
        .navigationTitle("Second Title")
    List(1..<50) { i in
        Text("Row \(i)")
    }
    .navigationTitle("Third Title")
}
.tabViewStyle(.verticalPage)

Улучшение прорисовки и анимаций

Добавление шейдеров Metal к представлениям SwiftUI с использованием эффектов слоя

Metal — это графический API от Apple, который позволяет разработчикам максимально эффективно использовать графический и вычислительный потенциал современных устройств Apple. Шейдеры, написанные на языке Metal, позволяют создавать разнообразные визуальные эффекты, такие как изменение цветов, форм и т.д.

Начиная с версии iOS 17 SwiftUI обеспечивает широкую интеграцию с шейдерами Metal прямо на уровне представлений - мы можем манипулировать цветами, формами и многим другим с высокой производительностью.

Для этого необходимо выполнить три шага:

  1. Создание файла Metal с вашим шейдером. Он должен иметь точную сигнатуру функции, которая варьируется в зависимости от того, какой эффект вы пытаетесь применить.

  2. Привязка эффектов к вашим SwifUI представлениям

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

Автор предлагает набор созданных им шейдеров Metal для экспериментов: sample SwiftUI Metal shaders. Каждый из них выглядит примерно так:

[[ stitchable ]] half4 checkerboard(float2 position, half4 currentColor, float size, half4 newColor) {
    uint2 posInChecks = uint2(position.x / size, position.y / size);
    bool isColor = (posInChecks.x ^ posInChecks.y) & 1;
    return isColor ? newColor * currentColor.a : half4(0.0, 0.0, 0.0, 0.0);
}

Код этого шейдера создает эффект шахматной доски, где первые два параметра функции (позиция и текущий цвет) автоматически передаются в SwiftUI, а остальные параметры (размер и новый цвет) устанавливаются вручную:

  1. half4 checkerboard(float2 position, half4 currentColor, float size, half4 newColor) - это определение функции шейдера с именем "checkerboard". Функция принимает четыре параметра: position (позицию представления), currentColor (текущий цвет представления), size (размер квадратов шахматной доски), newColor (новый цвет).

  2. uint2 posInChecks = uint2(position.x / size, position.y / size); - здесь вычисляется позиция каждого квадрата шахматной доски путем деления координат x и y позиции на размер квадрата.

  3. bool isColor = (posInChecks.x ^ posInChecks.y) & 1; - это вычисление, чтобы определить, должен ли данный квадрат шахматной доски быть окрашен в новый цвет. Если результат операции XOR между координатами x и y является нечетным числом (& 1), то isColor будет true.

  4. return isColor ? newColor * currentColor.a : half4(0.0, 0.0, 0.0, 0.0); - если isColor равен true, функция возвращает новый цвет, умноженный на альфа-канал текущего цвета (таким образом, создается эффект прозрачности). Если isColor равно false, возвращается полностью прозрачный цвет (half4(0.0, 0.0, 0.0, 0.0)).

В SwiftUI используется динамический поиск членов. Это значит, что можно вызывать функции шейдера прямо по имени. Необязательные параметры, которые мы хотим передать в шейдер (в данном случае, размер и цвет), мы передаем прямо в вызове функции шейдера.

Image(systemName: "figure.run.circle.fill")
    .font(.system(size: 300))
    .colorEffect(ShaderLibrary.checkerboard(.float(10), .color(.blue)))

Приведенный код применяет шейдер к изображению. В этом примере используется системное изображение с именем "figure.run.circle.fill", изменяется его размер до 300 поинтов, а затем применяется эффект цвета с использованием нашей функции шейдера checkerboard(). В эту функцию передаются два параметра: размер квадратов шахматной доски (10 поинтов) и новый цвет (синий).

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

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

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

[[ stitchable ]] half4 noise(float2 position, half4 currentColor, float time) {
    float value = fract(sin(dot(position + time, float2(12.9898, 78.233))) * 43758.5453);
    return half4(value, value, value, 1) * currentColor.a;
}

Код шейдера noise создает эффект шума, основанный на времени. Значение шума вычисляется с помощью функций sin и dot и затем применяется к текущему цвету представления, создавая анимированный эффект шума.

Далее мы можем использовать этот шейдер в представлении TimelineView:

struct ContentView: View {
    let startDate = Date()

    var body: some View {
        TimelineView(.animation) { context in
            Image(systemName: "figure.run.circle.fill")
                .font(.system(size: 300))
                .colorEffect(ShaderLibrary.noise(.float(startDate.timeIntervalSinceNow)))
        }
    }
}

В результате получается анимированный шейдер, который меняется со временем, создавая динамический эффект на представлении:

Если в шейдер требуется передавать цвет из вашего представления, то для этого в Metal файл необходимо внести некоторые изменения. Во-первых, нужно добавить строчку #include <SwiftUI/SwiftUI_Metal.h> в начало вашего файла. Это позволит использовать набор функций и типов данных, определенных в SwiftUI для Metal. Во-вторых, нужно убедиться, что подпись вашего шейдера (т.е. объявление вашей функции шейдера) принимает и позицию, и экземпляр SwiftUI::Layer.

В данном случае, автор представляет пример простого шейдера пикселизации:

[[ stitchable ]] half4 pixellate(float2 position, SwiftUI::Layer layer, float strength) {
    float min_strength = max(strength, 0.0001);
    float coord_x = min_strength * round(position.x / min_strength);
    float coord_y = min_strength * round(position.y / min_strength);
    return layer.sample(float2(coord_x, coord_y));
}

В начале функции происходит проверка входного параметра "сила" (strength). Если этот параметр слишком близок к нулю, то деление на него может вызвать проблемы, поэтому используется функция max(), чтобы установить минимальное значение "силы" в 0.0001.

Затем каждая координата пикселя (position.x и position.y) делится на "силу" пикселизации, округляется до ближайшего целого числа и умножается обратно на "силу". Это создает эффект пикселизации, где каждый пиксель заменяется на крупный "суперпиксель".

Важной частью этого процесса является функция layer.sample(). Она считывает цвет в заданной позиции из представления (view), к которому применяется шейдер. Это позволяет шейдеру применять эффект пикселизации к любому представлению.

Чтобы применить шейдер, его нужно вызывать как эффект слоя (layer):

Image(systemName: "figure.run.circle.fill")
    .font(.system(size: 300))
    .layerEffect(ShaderLibrary.pixellate(.float(10)), maxSampleOffset: .zero)

Метод .layerEffect() вызывается для представления, и в него передаются шейдер пикселизации и параметр "сила". Это указывает SwiftUI, что нам нужно использовать весь слой и позицию каждого пикселя для реализации эффекта. Таким образом, шейдер пикселизации применяется к изображению:

Ещё один тип эффекта активируется с помощью модификатора distortionEffect(). Этот модификатор позволяет "искажать" изображение, перемещая пиксели из одного места в другое.

Приведенный пример кода шейдера называется "wave". Это простой шейдер, который создает эффект волны на изображении:

[[ stitchable ]] float2 wave(float2 position, float time) {
    return position + float2 (sin(time + position.y / 20), sin(time + position.x / 20)) * 5;
}

В этом шейдере принимаются два аргумента: позиция пикселя (position) и время (time). Эта функция возвращает новую позицию для каждого пикселя. Новая позиция рассчитывается как исходная позиция пикселя, смещенная на некоторое значение вдоль осей x и y. Смещение вычисляется как синусоидальная функция от времени и координаты пикселя, умноженная на 5. Это создает эффект волны, в котором пиксели "волнуются" во времени.

Этот шейдер можно использовать в купе с модификатором distortionEffect():

struct ContentView: View {
    let startDate = Date()

    var body: some View {
        TimelineView(.animation) { context in
            Image(systemName: "figure.run.circle.fill")
                .font(.system(size: 300))
                .distortionEffect(ShaderLibrary.simpleWave(.float(startDate.timeIntervalSinceNow)), maxSampleOffset: .zero)
        }
    }
}

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

[[ stitchable ]] float2 complexWave(float2 position, float time, float2 size, float speed, float strength, float frequency) {
    float2 normalizedPosition = position / size;
    float moveAmount = time * speed;

    position.x += sin((normalizedPosition.x + moveAmount) * frequency) * strength;
    position.y += cos((normalizedPosition.y + moveAmount) * frequency) * strength;

    return position;
}

Шейдер complexWave требует размер представления и имеет параметры для настройки скорости, силы и частоты волны. В этом шейдере позиция пикселя нормализуется относительно размера представления, затем рассчитывается величина смещения на основе времени и скорости. Далее, к координатам x и y пикселя прибавляются значения функций синуса и косинуса, в которые передаются нормализованная позиция пикселя и величина смещения, умноженные на частоту волны и усиленные заданной силой. В результате возвращается новая позиция пикселя.

Для использования этого шейдера понадобятся модификаторы visualEffect() и distortionEffect():

struct ContentView: View {
    let startDate = Date()

    var body: some View {
        TimelineView(.animation) { context in
            Image(systemName: "figure.run.circle.fill")
                .font(.system(size: 300))
                .visualEffect { content, proxy in
                    content
                        .distortionEffect(ShaderLibrary.complexWave(
                            .float(startDate.timeIntervalSinceNow),
                            .float2(proxy.size),
                            .float(0.5),
                            .float(8),
                            .float(10)
                        ), maxSampleOffset: .zero)
                }
        }
    }
}

Их совместное использование позволяет нам считывать размер представления и учитывать его в расчетах шейдера:

Для нашего последнего примера шейдера создадим простой фильтр тиснения, с использованием Slider для контроля силы тиснения. Фильтр тиснения (или эмбоссинг) — это вид обработки изображений, который создает эффект трехмерности, обычно путем изменения яркости пикселей в зависимости от их положения относительно источника света.

Сначала добавьте шейдер в ваш файл Metal:

[[ stitchable ]] half4 emboss(float2 position, SwiftUI::Layer layer, float strength) {
    half4 current_color = layer.sample(position);
    half4 new_color = current_color;

    new_color += layer.sample(position + 1) * strength;
    new_color -= layer.sample(position - 1) * strength;

    return half4(new_color);
}

Таким образом, этот код создает эффект тиснения, добавляя или убирая яркость от пикселей в зависимости от их положения, что создает впечатление трехмерности.

Теперь мы можем использовать этот шейдер с модификатором layerEffect():

struct ContentView: View {
    @State private var strength = 3.0

    var body: some View {
        VStack {
            Image(systemName: "figure.run.circle.fill")
                .foregroundStyle(.linearGradient(colors: [.orange, .red], startPoint: .top, endPoint: .bottom))
                .font(.system(size: 300))
                .layerEffect(ShaderLibrary.emboss(.float(strength)), maxSampleOffset: .zero)

            Slider(value: $strength, in: 0...20)
        }
        .padding()
    }
}

Теперь, как вы видите, интеграция шейдеров Metal в представления SwiftUI стала довольно простой, открывая обширный спектр спецэффектов без значительных трудозатрат.

Динамическое изменение внешнего вида представления в зависимости от его размера и положения

Модификатор visualEffect() в SwiftUI позволяет считывать геометрические величины представления без использования контейнера GeometryReader. Это значит, что мы можем использовать размер и расположение представления, не влияя на его поведение при размещении.

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

Однако, при этом visualEffect() может использоваться для изменения некоторых аспектов внешнего вида элемента, которые не влияют на его размещение. Например, он может изменять смещение (то есть позицию элемента относительно его нормального положения) и масштаб (то есть размер элемента относительно его нормального размера).

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

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

struct ContentView: View {
    var body: some View {
        ScrollView {
            ForEach(0..<100) { i in
                Text("Row \(i)")
                    .font(.largeTitle)
                    .frame(maxWidth: .infinity)
                    .visualEffect { content, proxy in
                        content.blur(radius: blurAmount(for: proxy))
                    }
            }
        }
    }

    func blurAmount(for proxy: GeometryProxy) -> Double {
        let scrollViewHeight = proxy.bounds(of: .scrollView)?.height ?? 100
        let ourCenter = proxy.frame(in: .scrollView).midY
        let distanceFromCenter = abs(scrollViewHeight / 2 - ourCenter)
        return Double(distanceFromCenter) / 100
    }
}

Совет: Вызов proxy.frame(in: .scrollView) позволяет определить размер данного представления в самом внутреннем представлении прокрутки, которое его содержит.

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

struct ContentView: View {
    @State private var rotationAmount = 0.0

    var body: some View {
        Grid {
            ForEach(0..<3) { _ in
                GridRow {
                    ForEach(0..<3) { _ in
                        Circle()
                            .fill(.green)
                            .frame(width: 100, height: 100)
                            .visualEffect { content, proxy in
                                content.hueRotation(.degrees(proxy.frame(in: .global).midY / 2))
                            }
                    }
                }
            }
        }
        .rotationEffect(.degrees(rotationAmount))
        .onAppear {
            withAnimation(.linear(duration: 5).repeatForever(autoreverses: false)) {
                rotationAmount = 360
            }
        }
    }
}

Модификатор visualEffect() в SwiftUI предназначен только для изменения внешнего вида элемента интерфейса, таких как цвета, размытие или освещение. Этот модификатор не предназначен для изменения содержимого представления.

Анимация SF Symbols

SwiftUI предоставляет модификатор symbolEffect() для добавления встроенных эффектов анимации для SF Symbols, что позволяет создать реально впечатляющие эффекты с минимальными усилиями.

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

struct ContentView: View {
    @State private var petCount = 0

    var body: some View {
        Button(action: { petCount += 1 }) {
            Label("Pet the Dog", systemImage: "dog")
        }
        .symbolEffect(.bounce, value: petCount)
        .font(.largeTitle)
    }
}

Вы также можете попробовать использовать .pulse для анимации прозрачности, но настоящий творческий подход проявляется при использовании SF Symbols, которые имеют несколько слоев, поскольку их можно анимировать по отдельности или вместе.

По умолчанию, слои анимируются индивидуально, поэтому такой код создает волновой эффект на иконке "mail.stack":

struct ContentView: View {
    @State private var isFavorite = false

    var body: some View {
        Button(action: { isFavorite.toggle() }) {
            Label("Activate Inbox Zero", systemImage: "mail.stack")
        }
        .symbolEffect(.bounce.down, value: isFavorite)
        .font(.largeTitle)
    }
}

Честно говоря, доступно множество вариаций, и вы даже можете добавить дополнительные параметры для настройки скорости и числа повторений.

Например, следующий код анимирует символ три раза со скоростью в три раза выше обычной:

struct ContentView: View {
    @State private var isFavorite = false

    var body: some View {
        Button(action: { isFavorite.toggle() }) {
            Label("Activate Inbox Zero", systemImage: "mail.stack")
        }
        .symbolEffect(.bounce, options: .speed(3).repeat(3), value: isFavorite)
        .font(.largeTitle)
    }
}

Анимация с переменным цветом особенно эффектна, поскольку SF Symbols позволяет управлять отображением анимации каждого слоя - .variableColor.iterative окрашивает каждый слой по очереди, .variableColor.cumulative добавляет каждый новый слой к уже окрашенным слоям, и вы можете добавить reversing к любому из этих вариантов, чтобы анимация проигрывалась вперед, а затем назад.

Вот более объемный пример, иллюстрирующий множество возможных вариантов использования:

struct ContentView: View {
    @State private var animationsRunning = false

    var body: some View {
        Button("Start Animations") {
            withAnimation {
                animationsRunning.toggle()
            }
        }

        VStack {
            HStack {
                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.iterative, value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.cumulative, value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.reversing.iterative, value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.reversing.cumulative, value: animationsRunning)
            }

            HStack {
                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.iterative, options: .repeating, value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.cumulative, options: .repeat(3), value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.reversing.iterative, options: .speed(3), value: animationsRunning)

                Image(systemName: "square.stack.3d.up")
                    .symbolEffect(.variableColor.reversing.cumulative, options: .repeat(3).speed(3), value: animationsRunning)
            }
        }
        .font(.largeTitle)
    }
}

И наконец, если вы сохраняете свои представления неизменными, а лишь меняете их содержимое - например, переключаете иконку для представления Label на основе действий пользователя - тогда вам следует использовать модификатор contentTransition() вместе с одним из вариантов для смены иконок.

В данном примере используется переход .replace, чтобы старая иконка плавно исчезла, а на ее месте появилась новая:

struct ContentView: View {
    @State private var isFavorite = false

    var body: some View {
        VStack {
            Button {
                withAnimation {
                    isFavorite.toggle()
                }
            } label: {
                Label("Toggle Favorite", systemImage: isFavorite ? "checkmark": "heart")
            }
            .contentTransition(.symbolEffect(.replace))
        }
        .font(.largeTitle)
    }
}

Вызов завершающего блока замыкания по завершению анимации

В SwiftUI, функция withAnimation() может опционально использоваться с колбэком при завершении, в котором задается код, выполняющийся по окончании анимации. Это может быть местом для изменения состояния вашей программы, а также предоставляет простой метод для цепочки анимаций – анимируя что-то сначала, а затем следующее.

Вот пример создания кнопки, которая сначала масштабируется в размере, а затем становится прозрачной:

struct ContentView: View {
    @State private var scaleUp = false
    @State private var fadeOut = false

    var body: some View {
        Button("Tap Me!") {
            withAnimation {
                scaleUp = true
            } completion: {
                withAnimation {
                    fadeOut = true
                }
            }
        }
        .scaleEffect(scaleUp ? 3 : 1)
        .opacity(fadeOut ? 0 : 1)
    }
}

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

Стандартное поведение функции withAnimation() в SwiftUI предполагает, что анимация считается завершенной, несмотря на этот "хвост" из микро движений. Однако, если вы хотите дождаться полного окончания анимации, вы можете переопределить данное поведение по умолчанию вот так:

struct ContentView: View {
    @State private var scaleUp = false
    @State private var fadeOut = false

    var body: some View {
        Button("Tap Me!") {
            withAnimation(.bouncy, completionCriteria: .removed) {
                scaleUp = true
            } completion: {
                withAnimation {
                    fadeOut = true
                }
            }
        }
        .scaleEffect(scaleUp ? 3 : 1)
        .opacity(fadeOut ? 0 : 1)
    }
}

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

Совет: Если вам нужны более сложные эффекты, вместо использования колбэка, лучше смотреть в сторону PhaseAmimator и модификатор phaseAnimator.

Создание многоступенчатых анимаций с использованием PhaseAnimator

В SwiftUI представление PhaseAnimator и модификатор phaseAnimator позволяют создавать многоступенчатые анимации, переключаясь между фазами анимации непрерывно, либо при активации определенного триггера.

Процесс создания таких многофазовых анимаций состоит из трех этапов:

  1. Установка фаз: Фазы в этом контексте представляют собой этапы или шаги анимации. Это может быть любой тип последовательности, хотя работа с перечислением CaseIterable может оказаться более простой.

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

  3. Добавление триггера (опционально): Триггер в этом контексте - это событие или условие, которое заставляет анимацию начинаться сначала. Без этого триггера анимация будет непрерывно прокручиваться, переходя от одной фазы к другой.

Например, в следующем фрагменте кода создается простая анимация, которая сначала делает текст минимального размера и невидимым, затем увеличивает его до оригинального размера, при этом делая полностью видимым, и, наконец, доводит его до значительного размера, снова делая невидимым. Для обозначения различных уровней масштабирования используется массив чисел 0, 1 и 3, представляющих собой соответствующие проценты масштабирования (0%, 100% и 300%). При этом текст становится видимым, когда его размер достигает 100% (число 1 в массиве):

Text("Hello, world!")
    .font(.largeTitle)
    .phaseAnimator([0, 1, 3]) { view, phase in
        view
            .scaleEffect(phase)
            .opacity(phase == 1 ? 1 : 0)
    }

Модификатор phaseAnimator применяется к одному элементу интерфейса. Если анимацию необходимо применить сразу к нескольким объектам, то вместо него можно использовать представление PhaseAnimator. В таком случае, обёрнутые в этом представление элементы, будут перемещаться между фазами анимации одновременно. Это удобно, если необходимо, чтобы несколько элементов интерфейса анимировались синхронно:

VStack(spacing: 50) {
    PhaseAnimator([0, 1, 3]) { value in
        Text("Hello, world!")
            .font(.largeTitle)
            .scaleEffect(value)
            .opacity(value == 1 ? 1 : 0)

        Text("Goodbye, world!")
            .font(.largeTitle)
            .scaleEffect(3 - value)
            .opacity(value == 1 ? 1 : 0)
    }
}

Для перехода из одной фазы анимации в другую так же можно использовать перечисление. Перечисление может иметь соответствующие "сырые" или базовые значения (в данном случае - числа, которые определяют степень масштабирования и прозрачности), но это не обязательное условие. Ниже приведен пример того же сценария анимации, но уже с использованием перечисления:

enum AnimationPhase: Double, CaseIterable {
    case fadingIn = 0
    case middle = 1
    case zoomingOut = 3
}

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .phaseAnimator(AnimationPhase.allCases) { view, phase in
                view
                    .scaleEffect(phase.rawValue)
                    .opacity(phase.rawValue == 1 ? 1 : 0)
            }
    }
}

Аниматор фаз можно настроить так, чтобы он не циклически повторял анимацию, а запускал ее по команде. Это можно сделать, добавив к аниматору значение-триггер, которое будет отслеживаться SwiftUI. Значением может быть случайны UUID или постоянно увеличивающееся число. При каждом изменении значения, SwiftUI будет сбрасывать аниматор и полностью воспроизводить анимацию.

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

enum AnimationPhase: CaseIterable {
    case start, middle, end
}

struct ContentView: View {
    @State private var animationStep = 0

    var body: some View {
        Button("Tap Me!") {
            animationStep += 1
        }
        .font(.largeTitle)
        .phaseAnimator(AnimationPhase.allCases, trigger: animationStep) { content, phase in
            content
                .blur(radius: phase == .start ? 0 : 10)
                .scaleEffect(phase == .middle ? 3 : 1)
        }
    }
}

Для лучшей читаемости кода к фазам анимации можно применить вычисляемые свойства, определенные внутри перечисления:

enum AnimationPhase: CaseIterable {
    case fadingIn, middle, zoomingOut

    var scale: Double {
        switch self {
        case .fadingIn: 0
        case .middle: 1
        case .zoomingOut: 3
        }
    }

    var opacity: Double {
        switch self {
        case .fadingIn: 0
        case .middle: 1
        case .zoomingOut: 0
        }
    }
}

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .font(.largeTitle)
            .phaseAnimator(AnimationPhase.allCases) { content, phase in
                content
                    .scaleEffect(phase.scale)
                    .opacity(phase.opacity)
            }
    }
}

Комбинирование форм для создания новых фигур

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

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

Circle()
    .union(.capsule.inset(by: 100))
    .fill(.blue)

Использование одной формы обладает преимуществом: результат выглядит естественным при добавлении прозрачности, поскольку вы рендерите не две накладывающиеся формы по отдельности, а объединяете их в одну.

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

Circle()
    .lineSubtraction(.rect.inset(by: 30))
    .stroke(style: .init(lineWidth: 20, lineCap: .round))
    .padding()

Или мы могли бы разместить один круг слева, а затем вычесть другой круг, смещенный вправо:

Circle()
    .offset(x: -100)
    .symmetricDifference(.circle.offset(x: 100))
    .fill(.red)
    .padding()

Изменение размера представления относительно его контейнера

Модификатор containerRelativeFrame() позволяет определять размеры представлений относительно их контейнера. Контейнером может выступать как всё окно приложения, так и ScrollView или строка списка.

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

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

В качестве иллюстрации, в приведенном примере представлениям, находящимся в ScrollView, задается ширина, составляющая 2/5 ширины их контейнера:

ScrollView(.horizontal, showsIndicators: false) {
    HStack {
        ForEach(0..<10) { item in
            Text("Item \(item)")
                .foregroundStyle(.white)
                .containerRelativeFrame(.horizontal, count: 5, span: 2, spacing: 10)
                .background(.blue)
        }
    }
}

Первые два параметра модификатора, count и span, используются для задания соотношения размеров представления и контейнера. В данном случае горизонтальное пространство прокручиваемого представления (scroll view) разделяется на пять равных частей (count = 5), а каждое текстовое представление занимает две пятых этого пространства (span = 2).

Такое разделение пространства позволяет пользователям видеть одновременно 2,5 представления - два полных и половину третьего, что подсказывает о возможности прокрутки.

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

Кроме того, containerRelativeFrame() позволяет задавать размеры по обеим осям, горизонтальной и вертикальной ([.horizontal, .vertical]), а также указывать пользовательское выравнивание представления в контейнере при помощи параметра alignment.

Прочие интересные нововведения

Интеграция встроенных покупок (in-app purchases) в SwiftUI

При импорте StoreKit вы получаете возможность использовать StoreView, SubscriptionStoreView и ProductView для использования встроенных покупок, включая запуск процесса покупки. Однако учтите, что вам все равно придется выполнить значительную часть работы со StoreKit, включая обработку транзакций покупки и тому подобное. Также стоит учесть, что Apple может потребовать применения этого стандартного интерфейса в будущих обновлениях.

Важно: Для работы со StoreKit, его необходимо импортировать в самом начале файла.

В самом простом варианте реализации вам нужно добавить ProductView с одним идентификатором продукта, вот так:

// just show a single product
ProductView(id: "com.hackingwithswift.plus.subscription")

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

VStack {
    Text("Welcome to my store")
        .font(.title)

    ProductView(id: "com.hackingwithswift.plus.subscription") {
        Image(systemName: "crown")
    }
    .productViewStyle(.compact)
    .padding()
}

Если необходимо представить сразу несколько продуктов, то у вас есть два пути. Во-первых, вы можете на свой вкус организовать группы ProductView в пользовательском интерфейсе. Во-вторых, вы можете воспользоваться StoreView, и указать список ID продуктов для отображения. Вот пример такого подхода:

VStack {
    Text("Hacking with Swift+")
        .font(.title)

    StoreView(ids: ["com.hackingwithswift.plus.subscription", "com.hackingwithswift.plus.lifetime"])
}

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

Совет: ProductView, StoreView и SubscriptionStoreView в iOS автоматически проверяют настройки Screen Time пользователя, чтобы убедиться, что он разрешил встроенные покупки.

Screen Time — это функция в iOS, которая позволяет пользователям (или их родителям/опекунам) контролировать время, проведенное с устройством, а также ограничивать доступ к определенным функциям и приложениям, включая встроенные покупки. Таким образом, если встроенные покупки отключены в настройках Screen Time, эти элементы интерфейса автоматически учтут это.

StoreView отлично справляется с задачей отображения встроенных покупок, но, на мой взгляд, истинная ценность заключается в использовании SubscriptionStoreView для встроенных подписок. Большинство разработчиков сталкиваются с трудностями при попытке создать удовлетворительный пользовательский опыт с подписками, который бы соответствовал требованиям команды по проверке приложений (App Review) в Apple. Ключевым аспектом здесь является ясное и четкое представление условий и положений подписки для пользователей.

В базовом варианте вам просто нужно получить идентификаторы продуктов для отображения, но у вас также есть возможность настроить видимость определенных кнопок. К примеру, можно добавить кнопки для восстановления покупок или активации промокода:

SubscriptionStoreView(productIDs:  ["com.hackingwithswift.plus.subscription"])
    .storeButton(.visible, for: .restorePurchases, .redeemCode)

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

struct ContentView: View {
    @State private var showingSignIn = false

    var body: some View {
        SubscriptionStoreView(productIDs:  ["com.hackingwithswift.plus.subscription"])
            .storeButton(.visible, for: .restorePurchases, .redeemCode, .policies, .signIn)
            .subscriptionStorePolicyDestination(for: .privacyPolicy) {
                Text("Privacy policy here")
            }
            .subscriptionStorePolicyDestination(for: .termsOfService) {
                Text("Terms of service here")
            }
            .subscriptionStoreSignInAction {
                showingSignIn = true
            }
            .sheet(isPresented: $showingSignIn) {
                Text("Sign in here")
            }
            .subscriptionStoreControlStyle(.prominentPicker)
    }
}

Совет: Политика конфиденциальности, как и условия обслуживания могут быть представлены не только в виде представлений на экране, но и в виде URL-адресов.

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

В качестве примера можно привести отображение собственного рекламного заголовка для моих подписок с голубым градиентом на заднем плане:

SubscriptionStoreView(productIDs:  ["com.hackingwithswift.plus.subscription"]) {
    VStack {
        Text("HWS+")
            .font(.largeTitle)
            .fontWeight(.black)

        Text("Take your Swift learning to the next level by subscribing to Hacking with Swift+!")
            .multilineTextAlignment(.center)
    }
    .foregroundStyle(.white)
    .containerBackground(.blue.gradient, for: .subscriptionStore)
}
.storeButton(.visible, for: .restorePurchases, .redeemCode)
.subscriptionStoreControlStyle(.prominentPicker)

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

В следующем фрагменте кода мы выводим уведомление при старте покупки продукта, а затем, в зависимости от исхода транзакции, показываем одно из двух возможных сообщений:

ProductView(id: "com.hackingwithswift.plus.subscription") {
    Image(systemName: "crown")
}
.productViewStyle(.compact)
.padding()
.onInAppPurchaseStart { product in
    print("User has started buying \(product.id)")
}
.onInAppPurchaseCompletion { product, result in
    if case .success(.success(let transaction)) = result {
        print("Purchased successfully: \(transaction.signedDate)")
    } else {
        print("Something else happened")
    }
}

Да, в данном API есть некоторая неуклюжесть – двойной .success необходим, так как мы получаем два вложенных enum'а, внутренний из которых содержит подробности транзакции, которые могут быть использованы для внешней верификации покупки при необходимости.

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

Новый модификатор inspector()

Модификатор inspector() позволяет добавить представление инспектора, которое может появляться и исчезать в зависимости от действий пользователя. Принцип работы такой же, как в Xcode: инспектор появляется с правой стороны пользовательского интерфейса и представляет собой панель с атрибутами. Инспектор может эффективно совмещаться с NavigationStack или NavigationSplitView в зависимости от потребностей.

В качестве наглядного примера ниже представлен сценарий, когда представление инспектора вызывается при нажатии кнопки:

struct ContentView: View {
    @State private var isShowingInspector = false

    var body: some View {
        Button("Hello, world!") {
            isShowingInspector.toggle()
        }
        .font(.largeTitle)
        .inspector(isPresented: $isShowingInspector) {
            Text("Inspector View")
        }
    }
}

Если устройство обладает достаточным экраном, например, это полноэкранное приложение для iPad или macOS, инспектор будет располагаться сбоку от кнопки (или другого элемента интерфейса), позволяя пользователю одновременно видеть содержимое основного экрана и инспектора.

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

На устройствах с достаточными экранами ширину представления инспектора можно настраивать при помощи модификатора .inspectorColumnWidth(). Он позволяет задать фиксированный размер ширины инспектора, например, .inspectorColumnWidth(500). Это означает, что инспектор будет занимать фиксированное пространство шириной 500 поинтов.

Также этот модификатор можно использовать для задания диапазона размеров ширины, например, .inspectorColumnWidth(min: 50, ideal: 150, max: 200). Это означает, что ширина колонки инспектора будет варьироваться в зависимости от доступного пространства, но не будет меньше 50 поинтов и больше 200 поинтов. При этом идеальной считается ширина 150 поинтов:

struct ContentView: View {
    @State private var isShowingInspector = false

    var body: some View {
        Button("Hello, world!") {
            isShowingInspector.toggle()
        }
        .font(.largeTitle)
        .inspector(isPresented: $isShowingInspector) {
            Text("Inspector View")
                .inspectorColumnWidth(min: 50, ideal: 150, max: 200)
        }
    }
}

При первом отображении окна инспектора будет использовано идеальное значение ширины, которое было задано разработчиком при использовании модификатора.

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

SwiftUI позволяет добавлять сразу несколько инспекторов, хотя это кажется не очень хорошей идеей.

Новый модификатор onKeyPress()

SwiftUI представляет модификатор onKeyPress(), благодаря которому мы можем реагировать на события аппаратной клавиатуры, происходящие в приложении. Единственным условием является то, что представление, в котором отслеживается нажатие клавиш, должно быть в фокусе.

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

struct ContentView: View {
    @FocusState private var focused: Bool
    @State private var key = ""

    var body: some View {
        Text(key)
            .focusable()
            .focused($focused)
            .onKeyPress { press in
                key += press.characters
                return .handled
            }
            .onAppear {
                focused = true
            }
    }
}

return .handled сообщает SwiftUI, что событие нажатия клавиши было полностью обработано блоком кода, находящимся в блоке замыкания. Если же возвращается .ignored, то обработка события нажатия клавиши передаётся следующему представлению в иерархии, которое способно его обработать.

Примечание: Символы, получаемые при нажатии клавиши, не включают в себя модификаторы (например, Shift или Ctrl), что означает, что вы не получите заглавные буквы или специальные символы при их нажатии.

Вариации onKeyPress() позволяют отслеживать, на какой стадии находится обработка события нажатия клавиши. Например, в данном коде отслеживается только момент отпускания клавиши (фаза .up), и при этом выводит символы, соответствующие нажатой клавише:

Text(key)
    .onKeyPress(phases: .up) { press in
        print("Received \(press.characters)")
        return .handled
    }

Существует вариация, которая реагирует только на нажатие определенных типов клавиш:

Text(key)
    .onKeyPress(characters: .alphanumerics) { press in
        print("Received \(press.characters)")
        return .handled
    }

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

Text(key)
    .onKeyPress(keys: [.escape, "w", "q"]) { press in
        print("Received \(press.characters)")
        return .handled
    }

Управление отображением колонок NavigationSplitView в компактных макетах

Когда NavigationSplitView на устройстве с маленьким экраном, например на iPhone или в портретной ориентации на iPad, SwiftUI пытается автоматически определить, какую панель лучше показать пользователю. Этот выбор часто оказывается правильным, однако вы всегда можете изменить его, задав желаемую колонку для компактного отображения в вашем Split View.

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

struct ContentView: View {
    @State private var preferredColumn = NavigationSplitViewColumn.detail

    var body: some View {
        NavigationSplitView(preferredCompactColumn: $preferredColumn) {
            Text("Sidebar View")
        } detail: {
            Text("Detail View")
        }
    }
}

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

Добавление тактильных эффектов с использованием сенсорной обратной связи

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

Чтобы активировать обратную связь, просто присоедините модификатор sensoryFeedback() к любому элементу интерфейса, указав желаемый тип эффекта и триггер – момент воспроизведения эффекта. SwiftUI будет наблюдать за изменением значения условия и каждый раз при его изменении запускать ваш тактильный эффект.

Возьмем для примера кнопку, которая отмечает выполнение задачи. Тактильный эффект можно воспроизвести в момент ее выполнения:

struct ContentView: View {
    @State private var taskIsComplete = false

    var body: some View {
        Button("Mark Complete") {
            taskIsComplete = true
        }
        .sensoryFeedback(.success, trigger: taskIsComplete)
    }
}

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

struct ContentView: View {
    @State private var randomNumber = 0.0

    var body: some View {
        Button("Mark Complete") {
            randomNumber = Double.random(in: 0...1)
        }
        .sensoryFeedback(trigger: randomNumber) { oldValue, newValue in
            let amount = abs(oldValue - newValue)
            return .impact(flexibility: .solid, intensity: amount)
        }
    }
}

И наконец, вы можете установить постоянный тактильный эффект и определить условия его вызова, создав свою функцию сравнения. К примеру, в следующем коде тактильный эффект .success активируется, когда разница между двумя случайными числами составляет более 0.5:

struct ContentView: View {
    @State private var randomNumber = 0.0

    var body: some View {
        Button("Mark Complete") {
            randomNumber = Double.random(in: 0...1)
        }
        .sensoryFeedback(.success, trigger: randomNumber) { oldValue, newValue in
            abs(oldValue - newValue) > 0.5
        }
    }
}

Уведомление пользователя о недоступности контента

В SwiftUI имеется специальный тип ContentUnavailableView, созданный для того, чтобы демонстрировать пользователям, что на данный момент для просмотра ничего нет. Например, в случае, если их поиск не привел к результатам, использование ContentUnavailableView будет намного лучше, чем отображение пустого экрана.

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

struct ContentView: View {
    var body: some View {
        ContentUnavailableView.search
    }
}

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

При желании, вы можете настроить его, добавив то, что искал пользователь:

ContentUnavailableView.search(text: "Life, the Universe, and Everything")

Кроме того вы также можете изменить иконку и описание:

Считывание значений компонентов RGB из цвета

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

Процесс раскрытия цветового значения делится на два этапа: в начале мы получаем доступ к текущему окружению, затем эта информация передается в метод resolve(in:) выбранного цвета. Полученные таким образом данные можно сохранить с помощью Codable или другого подходящего варианта.

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

struct ContentView: View {
    @Environment(\.self) var environment
    @State private var color = Color.red
    @State private var resolvedColor: Color.Resolved?

    var body: some View {
        VStack {
            ColorPicker("Select your favorite color", selection: $color)

            if let resolvedColor {
                Text("Red: \(resolvedColor.red)")
                Text("Green: \(resolvedColor.green)")
                Text("Blue: \(resolvedColor.blue)")
                Text("Opacity: \(resolvedColor.opacity)")
            }
        }
        .padding()
        .onChange(of: color, initial: true, getColor)
    }

    func getColor() {
        resolvedColor = color.resolve(in: environment)
    }
}

Важно: Данные предоставляются в формате Float, а не Double.

В этом коде переменная resolved получает тип Color.Resolved, который может быть преобразован обратно в новый объект Color или переведен в JSON или что-то подобное с использованием Codable.

Например, мы можем преобразовать наш цвет, полученный с помощью resolved в JSON следующим образом:

struct ContentView: View {
    @Environment(\.self) var environment
    @State private var color = Color.red

    @State private var resolvedColor: Color.Resolved?
    @State private var colorJSON = ""

    var body: some View {
        VStack {
            ColorPicker("Select your favorite color", selection: $color)

            if let resolvedColor {
                Text("Red: \(resolvedColor.red)")
                Text("Green: \(resolvedColor.green)")
                Text("Blue: \(resolvedColor.blue)")
                Text("Opacity: \(resolvedColor.opacity)")
            }

            Text("Color JSON: \(colorJSON)")
        }
        .padding()
        .onChange(of: color, initial: true, getColor)
    }

    func getColor() {
        resolvedColor = color.resolve(in: environment)

        if let colorData = try? JSONEncoder().encode(resolvedColor) {
            colorJSON = String(decoding: colorData, as: UTF8.self)
        }
    }
}

Примечание: Так как мы работаем с дробными числами, то в итоговых результатах могут возникнуть незначительные отклонения.

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

let resolvedColor = Color.Resolved(red: 0, green: 0.6, blue: 0.9, opacity: 1)

Rectangle()
    .fill(Color(resolvedColor).gradient)
    .ignoresSafeArea()

Созадние кнопок, повторяющих свое действие, пока кнопка удерживается

В SwiftUI существует специальный модификатор buttonRepeatBehavior(), который вызывает повторное действие кнопки, пока пользователь удерживает её нажатой. Скорость вызова действия постепенно увеличивается, поэтому чем дольше пользователь удерживает кнопку нажатой, тем быстрее происходит вызов действия.

В качестве примера, если нажать такую кнопку, она добавит 1 к счётчику, но если удерживать кнопку нажатой, она будет добавлять 1 всё быстрее и быстрее:

struct ContentView: View {
    @State private var tapCount = 0

    var body: some View {
        Button("Tap Count: \(tapCount)") {
            tapCount += 1
        }
        .buttonRepeatBehavior(.enabled)
    }
}

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

Следующий пример кода позволит пользователю удерживать Shift + Return для многократного вызова нашей кнопки:

struct ContentView: View {
    @State private var tapCount = 0

    var body: some View {
        Button("Tap Count: \(tapCount)") {
            tapCount += 1
        }
        .buttonRepeatBehavior(.enabled)
        .keyboardShortcut(.return, modifiers: .shift)
    }
}

И это не всё...

Одновременная заливка и обводка фигур

В iOS 17 и более поздних версиях вы можете одновременно заполнять и обводить формы, просто накладывая модификаторы, вот так:

Circle()
    .stroke(.red, lineWidth: 20)
    .fill(.orange)
    .frame(width: 150, height: 150)

Это даже работает с несколькими обводками различных размеров:

Circle()
    .stroke(.blue, lineWidth: 45)
    .stroke(.green, lineWidth: 35)
    .stroke(.yellow, lineWidth: 25)
    .stroke(.orange, lineWidth: 15)
    .stroke(.red, lineWidth: 5)
    .frame(width: 150, height: 150)

В iOS 16 и более ранних версиях SwiftUI предоставляет модификаторы fill(), stroke() и strokeBorder() для настройки рисования форм, но не предусматривает встроенной возможности одновременного заполнения и обводки. Однако, мы можем достичь такого же эффекта двумя разными способами.

Первое решение - применить strokeBorder() для того, чтобы добавить контур вокруг вашей фигуры, после чего расположить уже заполненную форму на фоне с использованием модификатора background(). Пример ниже отрисовывает оранжевый круг с красным контуром:

Circle()
    .strokeBorder(.red, lineWidth: 20)
    .background(Circle().fill(.orange))
    .frame(width: 150, height: 150)

Второй вариант — наложить два круга вручную с помощью ZStack:

ZStack {
    Circle()
        .fill(.orange)

    Circle()
        .strokeBorder(.red, lineWidth: 20)
}
.frame(width: 150, height: 150)

Если вам нужно заполнить и обвести большое количество форм, стоит подумать о том, чтобы обернуть эту функциональность в расширение. Но стоит учесть, что модификатор strokeBorder() доступен только для InsettableShapes, поэтому вам потребуется создать два расширения – одно для работы с обычными фигурами с использованием stroke(), и другое - для InsettableShapes с использованием strokeBorder():

extension Shape {
    func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
        self
            .stroke(strokeStyle, lineWidth: lineWidth)
            .background(self.fill(fillStyle))
    }
}

extension InsettableShape {
    func fill<Fill: ShapeStyle, Stroke: ShapeStyle>(_ fillStyle: Fill, strokeBorder strokeStyle: Stroke, lineWidth: Double = 1) -> some View {
        self
            .strokeBorder(strokeStyle, lineWidth: lineWidth)
            .background(self.fill(fillStyle))
    }
}

Запуск кода при обновлении состояния представлений с использованием onChange()

Начиная с версии iOS 17 модификатор onChange() в SwiftUI позволяет привязать к любому элементу пользовательского интерфейса код, который будет выполняться, когда произойдет изменение определённого состояния в приложении. Это особенно ценно, поскольку нам не всегда доступны наблюдатели за свойствами, такие как didSet, вместе с конструкциями подобными @State.

Важно: Это поведение вступает в силу с версии iOS 17, а старое поведение становится задеприкейченным.

В случае работы с iOS 16 и более ранними версиями, onChange() принимает один параметр и возвращает его обновленное значение в блоке замыкания. К примеру, изменения имени можно отслеживать и выводить в реальном времени:

struct ContentView: View {
    @State private var name = ""

    var body: some View {
        TextField("Enter your name:", text: $name)
            .textFieldStyle(.roundedBorder)
            .onChange(of: name) { newValue in
                print("Name changed to \(name)!")
            }
    }
}

Если вы нацелены на работу с iOS 17 или более поздними версиями, существует вариант, который не требует параметров – вы можете просто прочитать свойство напрямую и быть уверенными в получении его нового значения, что отличается от работы версии с одним параметром в iOS 16 и ранее.

iOS 17 расширяет возможности и предлагает ещё две опции: одна из них поддерживает два параметра в замыкании, представляющих старое и новое значения, а вторая позволяет установить, должен ли исполняться код вашей функции при первоначальном показе элемента интерфейса.

Например, следующий принт выводит как старое, так и новое значение в процессе обновления значения:

struct ContentView: View {
    @State private var name = ""

    var body: some View {
        TextField("Enter your name", text: $name)
            .onChange(of: name) { oldValue, newValue in
                print("Changing from \(oldValue) to \(newValue)")
            }
    }
}

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

struct ContentView: View {
    @State private var name = ""

    var body: some View {
        TextField("Enter your name", text: $name)
            .onChange(of: name, initial: true) {
                print("Name is now \(name)")
            }
    }
}

Использование параметра initial: true – это удобный способ объединить функциональность в одном месте – вместо выполнения определенных действий в onAppear() и onChange(), вы можете сделать всё за один проход.

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

Вот как это может выглядеть:

extension Binding {
    func onChange(_ handler: @escaping (Value) -> Void) -> Binding<Value> {
        Binding(
            get: { self.wrappedValue },
            set: { newValue in
                self.wrappedValue = newValue
                handler(newValue)
            }
        )
    }
}

struct ContentView: View {
    @State private var name = ""

    var body: some View {
        TextField("Enter your name:", text: $name.onChange(nameChanged))
            .textFieldStyle(.roundedBorder)
    }

    func nameChanged(to value: String) {
        print("Name changed to \(name)!")
    }
}

Однако, перед тем как применить это, обязательно проведите анализ вашего кода с использованием Instruments – внедрение onChange() непосредственно в представление обеспечивает более высокую производительность, чем его присоединение к binding.

Новый API для работы со spring анимациями

SwiftUI предоставляет встроенные средства для создания анимаций типа spring, которые заставляют пружинить объект при остановке.

Если применить .spring() без дополнительных параметров, будет использовано стандартное поведение. Например, в данном случае создается spring анимация, при которой кнопка поворачивается на 45 градусов при каждом нажатии на нее:

struct ContentView: View {
    @State private var angle: Double = 0

    var body: some View {
        Button("Press here") {
            angle += 45
        }
        .padding()
        .rotationEffect(.degrees(angle))
        .animation(.spring(), value: angle)
    }
}

Для более комплексной настройки spring анимаций могут использовать различные параметры:

  1. Для совместимости с iOS 16 и более старыми версиями, требуется задать такие характеристики, как масса объекта, жесткость пружины, темп затухания и начальное ускорение.

  2. Если ваше приложение ориентировано только на iOS 17 и более новые версии, вы можете установить продолжительность анимации, а также по желанию добавить параметры отскока и смешивания.

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

struct ContentView: View {
    @State private var angle: Double = 0

    var body: some View {
        Button("Press here") {
            angle += 45
        }
        .padding()
        .rotationEffect(.degrees(angle))
        .animation(.interpolatingSpring(mass: 1, stiffness: 1, damping: 0.5, initialVelocity: 10), value: angle)
    }
}

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

Следующий код достигает схожего результата, но он совместим только с iOS 17 и более новыми версиями:

struct ContentView: View {
    @State private var scale = 1.0

    var body: some View {
        Button("Press here") {
            scale += 1
        }
        .scaleEffect(scale)
        .animation(.spring(duration: 1, bounce: 0.75), value: scale)
    }
}

Учитывая, насколько проще новый API по сравнению со старым эквивалентом, я бы посоветовал перейти на него, как можно скорее.

Прочие изменения

  • Модификатор foregroundColor() теперь считается устаревшим и его заменил новый - foregroundStyle()

  • Изменение цветов с использованием градиента будет происходить с анимацией

  • Spring анимация теперь является дефолтной в SwiftUI и содержит гораздо больше встроенных параметров, таких как .bouncy и .snappy, а сами анимации теперь создаются с помощью гораздо более простого API

  • Появилась возможность скруглять только определенные углы прямоугольника, а не все сразу

  • Упрощен синтаксис для заливки и обрезки фигур: .rect, .capsule и т.д.

  • При создании табличных представлений вместо ForEach(users, content: TableRow.init) можно писать просто ForEach(users)

Следующее изменение больше относится к Xcode, нежели к SwiftUI, но все же теперь все цвета и изображения, находящиеся в каталоге assets, теперь имеют статические имена, которые можно использовать в коде без строковых литералов. Например, мы можем написать Image(.dog) вместо `Image("dog").

Также появились новые API для выполнения анимаций с ключевыми кадрами, новые элементы для управления картами и многое другое.

Спасибо за внимание!

Комментарии (0)