Декларативное программирование — это парадигма программирования, в которой задаётся ожидаемый результат, а не способ его получения. Об истоках этой технологии, её отличиях от императивной парадигмы и удобстве её использования рассказывает iOS-разработчик red_mad_robot Саша Евсеев.

Императивное и декларативное программирование

Императивное программирование самое распространённое в мире. Это доказывают исследования Tiobe: императивные языки — Java, Python, JavaScript, C, C++ — доминируют в индустрии ПО.

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

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

А вот декларативное программирование работает совсем не так.

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

Мы так много раз написали слово «парадигма» в контексте написания кода, что пора его объяснить. В нашем случае парадигма подразумевает стиль написания кода и его организацию.

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

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

Основа декларативного программирования — математическая логика, синтез математики и логики. Современный её вариант предложил Готлоб Фреге в 1899 году, ещё до появления современных вычислительных машин и языков программирования.

Языки с декларативным стилем кода известны с 70-х годов XX века. Интересно, что сначала в программировании применялись мультипарадигменные языки, которые позволяли решать большую часть существовавших задач.

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

Фреймворками для iOS-платформы, написанными до появления официальных декларативных решений, были AsyncDisplayKit, KarrotFlex/FlexLayout, EasyPeasy, MondarianLayout.

Их цель была проста — отойти от медленного Auto Layout и предоставить высокую скорость разработки за счёт декларативного синтаксиса, так как в основе этих фреймворков лежит язык Swift.

Функциональные языки

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

Пример такого языка — SQL:

select studentID, FullName, sat_score
    from student
    where (studentID between 1 and 5 -- inclusive
        or studentID = 8
        or FullName like '%Maximo%')
        and sat_score NOT in (1000, 1400)
    order by FullName DESC;

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

Аналогичный пример — язык разметки HTML с применением CSS. С помощью его инструментов мы размечаем страницу так, как её представляем. И не указываем указания действия для отображения контента.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Hello World</title>
</head>
<style>
    body {
        display: flex;
        justify-content: center;
        height: 100vh;
        width: 100vw;
        align-items: center;
    }
    body span {
    align-items: center;
}
</style>
<body>
<span>Hello World!</span>
</body>
</html>

Ещё один классный пример — решение задачи на мультипарадигменном языке Lisp. При этом основу его составляет функциональная часть. Это значит, что если нужно написать что-то в императивном стиле, такой код стоит выделить отдельным синтаксисом.

;; Function f defined on complex numbers and a square area that contains only one root of the function, 
;; find this root.
(defun integrate-square-path (f start length precision)
  "f is the function to integrate.
   Start is a complex number: the lower left corner of the square.
   Length is the length of the side of square.
   Precision is the distance between two points that we consider acceptable."
  (let* ((sum 0) ;;The result would be summed there
         (n (ceiling (/ length precision))) ;;How many points on each side
         (step (float (/ length n))) ;;Distance between points
         (j 0) ;;index
         (side 0) ;;The number of side: from 0 to 3
         (d (complex step 0)) ;;Complex difference between two points
         (cur start)) ;;Current position 
    (loop (incf sum (* (funcall f cur) d)) ;;Increment the sum
          (incf cur d) ;;Change the position
          (incf j) ;;Increment the index
          (when (= j n) ;;Time to change the side
            (setf j 0)  
            (incf side)
            (setf d (case side  ;;Change the direction
                      (1 (complex 0 step))
                      (2 (complex (- step) 0))
                      (3 (complex 0 (- step)))
                      (4 (return sum))))))))

Всё зависит от состояния

В реализациях декларативных компонентов используется идея, в которой есть функция, зависящая от состояния. Результат такой функции — новый вариант верстки: func render (context) → UI.

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

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

Но что такое состояние — (state)? Это функция с двумя параметрами: событие и предыдущее состояние. State соответствует f(action, previous_state).

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

UIKit — Imperative

import UIKit

class UIKitView: UIView {
    private var input: String = ""
    
    lazy var textfield: UITextField = {
        let field = UITextField()
        field.translatesAutoresizingMaskIntoConstraints = false
        field.placeholder = "Enter some input: "
        field.addTarget(self, action: #selector(inputChanged), for: .editingChanged)
        return field
    }()
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        configurate()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    @objc func inputChanged(_ textfield: UITextField) {
        guard let newText = textfield.text else { return }
        input = newText
    }
    
    private func configurate() {
       addSubview(textfield)
        
        textfield.centerYAnchor.constraint(equalTo: centerYAnchor)
            .isActive = true
        textfield.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16)
            .isActive = true
        textfield.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16)
            .isActive = true
    }
}

SwiftUI — Declarative

import SwiftUI

struct SwiftUIView: View {
    @State private var input = ""
    
    var body: some View {
        TextField("Enter some input: ", text: $input)
            .border(.blue)
            .padding([.leading, .trailing], 16)
    }
}

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

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

«Что» против «как», или Декларативный UI против императивного

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

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

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

Написание кода здесь больше похоже на реализацию логической функции. Результат — определённое решение, в нашем случае — пользовательский интерфейс.

Соответственно, в декларативном и императивном синтаксисах отличаются и способы задания расположения элемента на экране.

От Interface Builder к Swift Preview

История Interface Builder началась в 1986 году с приложения для создания пользовательского интерфейса с помощью графического интерфейса.

Interface Builder
Interface Builder

По сути, это написанная на Lisp часть Xcode (он же Project Builder) — системы инструментов для разработчиков Apple Developer Connection.

Interface Builder был создан французским учёным-компьютерщиком и программистом Жан-Мари Халлотом. Он использовал инструменты объектно-ориентированного программирования в ExperLisp и интегрировал приложение с инструментами Macintosh.

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

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

Интересный факт: Тимом Бернерс-Ли из CERN использовал Interface Builder в разработке WorldWideWeb браузера.

Тогда, в 1988 году, Interface Builder, конечно, мало напоминал свой современный вариант. Но уже в 2011 году он стал входить в состав IDE Xcode 4.

Плюсы Interface Builder:

  • низкий порог входа,

  • работа и с отдельным кадром, и с композицией — речь о Storyboard, конечно,

  • удобное решение для несложных пользовательских интерфейсов в связке с Auto Layout.

Созданный файл сохранялся .xib/.nib или .storyboard в формате .xml и имел свои особенности.

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

В июне 2019 года на WWDC представили новый фреймворк — SwiftUI.

Swift Preview
Swift Preview

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

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

Интересно, что месяцем раньше — 7 мая — на Google I/O 2019 был представлен Jetpack compose, который так же, как SwiftUI, упрощает написание и обновление интерфейса приложения с помощью декларативного подхода.

Важной особенностью SwiftUI стал переход от Storyboard к Swift Preview.

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

В SwiftUI графическая презентация — это отражение кода. Она крепко с ним связана. Это сказалось на быстродействии графического компонента — Swift Preview позволяет отображать изменения в коде сразу же после их внесения в отображаемый элемент. При этом мы можем изменять отображение и из окна предпросмотра, и из кода.

Так времена, когда нам приходилось ждать компиляции и запуска приложения по 10 минут, практически ушли в далёкое прошлое.

Если очень нужно, мы можем использовать и компоненты из UIKit с помощью UIViewRepresentable в роли «обёртки».

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

Это стало возможно за счёт автоматической генерации дополнительных ресурсов и последующей подмены функций (method swizzling). В дальнейшем это передаётся в инструмент Xcode и интерпретируется в интерактивную графику.

AsyncDisplayKit. Популярный и декларативный

AsyncDisplayKit, он же Texture, был создан командой разработчиков Facebook (принадлежит компании Meta Platforms Inc., которая признана в России экстремистской, запрещена в России и внесена Росфинмониторингом в перечень террористов и экстремистов) для приложения Papers. Необходимость в этом фреймворке появилась из-за экспоненциального роста сложности расчётов интерфейсов.

Создатели AsyncDisplayKit попытались разгрузить основной поток. Ведь для плавного и отзывчивого интерфейса новый фрейм должен быть рассчитан меньше чем за 16 миллисекунд, а при загрузке операционной системы это число будет меньше.

Создатели AsyncDisplayKit планировали избавиться от следующих проблем:

  1. Выполнение операций расчёта интерфейса на главном потоке. Если в интерфейсе много правил (constraints), это снижает производительность.

  2. Применение своего расчёта, который выполняется быстрее.

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

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

Решение системы уравнений — универсальное средство расчёта интерфейса. Именно его и применила команда разработки Apple в UIKit.

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

Команда разработки AsyncDisplayKit вдохновилась идеями декларативного синтаксиса пользовательского интерфейса CSS Flexbox. Это заметно при рассмотрении механизма отрисовки в фреймворке.

override func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec {
        var content: [ASLayoutElement] = []
        let item = insetsSpec {
            $0.insets(.top(24.0), .bottom(32.0))
            $0.child = exampleTextNode
        }
        content.append(titleNode)
        content.append(item)
 
        if let hintMessageNode = anotherExampleNode {
            let anotherItem = stackSpec {
                $0.hBothStart()
                $0.spacing = 8.0
                $0.children = [
                    icon,
                    hintExampleNode.styled {
                        $0.flexShrink = 1.0
                        $0.flexGrow = 1.0
                    },
                ]
            }
            content.append(anotherItem)
        }
 
        return stackSpec {
            $0.vBothStart()
            $0.style.flexGrow = 1.0
            $0.children = [
                stackSpec {
                    $0.vBothStart()
                    $0.style.flexGrow = 1.0
                    $0.children = content
                },
                relativeSpec {
                    $0.style.flexGrow = 1.0
                    $0.centerBottom()
                    $0.child = submitBtn
                },
            ]
        }
    }

Хоть AsyncDisplayKit и использует структуру, очень похожую на UIKit, совместимости между Interface Builder/Auto Layout и Texture нет. Это связано с тем, что системы, разработанные Apple, обладают зависимостями, от которых создатели AsyncDisplayKit хотели избавиться.

Компоненты, билдер и не только

Builder

Function Builder — это функция языка, впервые представленная в Swift 5.1. Он поддерживает декларативный DSL SwiftUI, который позволяет создавать разнородные иерархии пользовательского интерфейса в удобочитаемой и лаконичной форме.

Основная идея в том, что мы берём результат выражения, включая вложенные выражения вроде if и switch, и формируем их в один результат, который становится возвращаемым значением текущей функции. Эта «сборка» контролируется билдером функции, который является кастомным атрибутом.

ViewBuilder — это атрибут параметра для параметров закрытия, создающих дочерние представления, что позволяет этим замыканиям предоставлять несколько дочерних представлений.

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

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen public struct VStack<Content> : View where Content : View {

    /// Creates an instance with the given spacing and horizontal alignment.
    ///
    /// - Parameters:
    ///   - alignment: The guide for aligning the subviews in this stack. This
    ///     guide has the same vertical screen coordinate for every child view.
    ///   - spacing: The distance between adjacent subviews, or `nil` if you
    ///     want the stack to choose a default distance for each pair of
    ///     subviews.
    ///   - content: A view builder that creates the content of this stack.
    @inlinable public init(alignment: HorizontalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: () -> Content)

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required ``View/body-swift.property`` property.
    public typealias Body = Never
}

В инициализаторе структуры виден параметр content. Это то самое замыкание, возвращающее объект, подписанный под протокол View.

Этим параметром обладают элементы интерфейса, получающие на вход несколько сгруппированных элементов: Group, VStack, HStack, List.

Наиболее примечательное в этой структуре — это наличие функции, которая возвращает нам один объект вместо нескольких. В чём подвох?

public static func buildBlock<C0, C1>(_ c0: C0, _ c1: C1) -> TupleView<(C0, C1)> where C0 : View, C1 : View

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

Чтобы реализовать этот объект, необходимо пометить структуру с помощью атрибута @resultBuilder.

Зачем нам это нужно? Чтобы создать кастомный объект, который сможет принимать в себя несколько объектов, и чтобы упростить запись и читабельность кода.

Впервые представленный в Swift версии 5.1 под именем Function Builder, он стал отличным решением для предметно-ориентированного языка, или коротко — DSL (domain-specific language).

Реализованный функционал Function Builder можно увидеть не только в собственных реализациях или View. Например, SceneBuilder, который нужен для построения сцен, используемых в навигации. Или RegexBuilder, необходимый для построения регулярного выражения.

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

А что если мы хотим добавить условие вхождения объекта в функцию? Тогда мы должны реализовать опциональный вариант этой функции, которая вернёт нам ConditionalContent<C0, C1>. В результате мы получаем строго типизированный объект, с которым можно работать в ран-тайме. Это позволяет применять оптимизации на этапе компиляции.

View components

По назначению компоненты делятся на следующие группы:

  1. Отображение и формы — RoundedRectangle, Rectangle, Ellipse, ContainerRelativeShape, Circle, Capsule, Image, Text, Canvas.

  2. Композиции — List, VStack, HStack, ZStack, Group, Form.

  3. Компоненты с пользовательским вводом и выводом — Button, Textfield, Toggle, Slider, Picker, DatePicker, SegmentedControl, ProgressView.

  4. Контейнеры — Alert, Popover, ActionSheet, NavigationView, TabView, HSplitView, VSplitView, TimelineView, Touchbar.

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

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

RoundedRectangle, Rectangle, Ellipse, ContainerRelativeShape, Circle, Capsule подписаны под протокол Shape, который мы отнесём к группе «Форма».

/// A 2D shape that you can use when drawing a view.
///
/// Shapes without an explicit fill or stroke get a default fill based on the
/// foreground color.
///
/// You can define shapes in relation to an implicit frame of reference, such as
/// the natural size of the view that contains it. Alternatively, you can define
/// shapes in terms of absolute coordinates.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol Shape : Animatable, View { … }

Сюда же относятся Text и Image, поскольку без модификаторов они будут только отображать заданный нами контент.

var body: some View {
    VStack {
        Circle()
           .frame(width: 100, height: 100, alignment: .center)
           .foregroundColor(.green)
        Text("Hello world")
        Image(systemName: "heart.fill")
           .foregroundColor(.green)
    }
}

Canvas — это интерпретация рисунка по координатам. Теперь она выглядит несколько непривычно, но не потеряла в информативности и наглядности.

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

VStack, HStack, ZStack и Group похожи по типу возвращаемого элемента. У каждого контейнера есть свой тип. Но он может меняться в зависимости от элементов, в него входящих.

VStack {
        Text("first text”)
        Text(“second text")
        Image(systemName: "heart.fill")
}

Структурно этот код выглядит вот так:

VStack<TupleView<(Text, Text, Image)>>

Например, добавление условия влияет на тип контейнера.

VStack {
    Text(“first text")
    if someCondition {
        Text(“second text")
    }
    Image(systemName: "heart.fill")
}

При использовании неполного условия структура немного меняется:

VStack<TupleView<(Text, Text?, Image)>>
VStack {
    Text("Hello world!")
    if somaCondition {
        Text("Hello world!")
    } else {
       Image(systemName: "heart.fill")
    }
}

А при применении полного условия происходят уже масштабные изменения с использованием дополнительной «обёртки»:

VStack<TupleView<(Text, _ConditionalContent<Text, Image>)>>

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

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

Чтобы список — List — работал правильно, важно обозначить признак уникальности в объектах, входящих в состав отображаемых элементов. Простой способ — подписать объект под протокол Identifiable.

Основная особенность элементов третьей группы — с пользовательским вводом и выводом — возможность менять состояние системы. Иными словами, воздействовать на внутренние поля объекта, в котором находятся.

Четвертая группа — контейнеры — позволяют определять способ вывода информации.

В случае с Alert или Popover мы увидим небольшое окно с возможностью выбора действия.

В случае с TabbarView, HSplitView, VSplitView (из-за их расположения) мы увидим несколько экранов в одном.

Выделим среди контейнеров специфичный TimelineView, поскольку он предоставляет доступ к временной составляющей состояния системы. Что нам это даёт? Возможность строить анимацию на основе времени.

ViewModifiers

ViewModifiers нужен, чтобы настраивать элементы с помощью цепочек вызовов модификаторов (chain modifiers).

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

Text("Hello, world!")
    .foregroundColor(.red)
    .bold()
    .padding()

Какие существуют типы модификаторов:

  • специфичные для компонента — .foregroundColor, .font, .bold, .resizable, .aspectRatio;

  • изменение формы — .clipShape, .clipped, .cornerRadius, .frame;

  • общий стиль — .shadow, .border, .padding;

  • дополнительные возможности — .contextMenu, .onTapGesture, .onDrag;

  • способ показа — .alert, .sheet;

  • анимация — .animate.

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

Text("Hello World!")
            .modifier(MyModifier

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

В этом примере модификатор .bold () не может быть применен к Text, потому что модификатор .frame меняет результирующий тип к View. А это делает невозможным применение требуемого модификатора.

Отдельно расскажем о модификаторе «Анимация». Она того стоит, поверьте.

Анимация — королева интерфейсов. Серьезно, она везде: от переходов с экрана на экран до нажатия кнопок и отображения курсора. Но это всё стандартная анимация, которая входит в возможности фреймворка. Если мы хотим модифицировать её или создать собственную, то нужно реализовывать её самостоятельно.

Писать анимацию на UIKit — непростое и трудоёмкое дело. Создание многоступенчатых, интерактивных анимаций стандартными средствами занимает немало времени.

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

Чтобы быстро реализовать неинтерактивную анимацию, стоит воспользоваться методом .animate.

func showMenu() {
        UIView.animate(withDuration: 0.3) {
            self.menuVC.view.frame = CGRect(
                x: 0,
                y: 60,
                width: UIScreen.main.bounds.size.width,
                height: UIScreen.main.bounds.size.height
            )
            self.addChild(self.menuVC)
            self.view.addSubview(self.menuVC.view)
        }
    }

Но как быть, если мы хотим интерактивную анимацию ещё и нескольких объектов? Тогда нам поможет UIViewPropertyAnimator и UIViewAnimateKeyframes. С помощью этих инструментов мы сможем создать контролируемую анимацию с возможностью отмены и многоступенчатыми модификациями элементов.

let animator = UIViewPropertyAnimator(
    duration: TimeInterval(0.22),
    curve: .easeInOut
)
animator.addAnimations {
    self.animatedView?.alpha = 1.0
}
animator.startAnimation()

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

С анимацией в императивной парадигме разобрались. Но как создать анимацию, используя декларативный синтаксис?

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

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

struct ContentView: View {
    @State var counter: Int = 0

    var body: some View {
        VStack(
            alignment: .center,
            spacing: 16,
            content: {
                Text("Button Pressed \(counter) times")
                    .transition(.slide).id(counter)
                Button("Press me", action: {
                    withAnimation(.easeIn(duration: 0.22)) {
                        counter += 1
                    }
                })
            }
        )
    }
}

Нажимая Press me, мы изменяем счетчик counter, от которого зависит состояние всего элемента. Он меняется — вложенные компоненты перерисовываются. Получается, что появляется состояние, которое говорит, что пора перерисовать текущий элемент, потому что у него сменилось состояние.

struct DotTimeView: View {
    @State private var index = 0

    let date: Date
    let delay: Double
    let color: Color

    private let keyFrames: [KeyFrame] = [
        KeyFrame(offset: 0, animation: .easeInOut(duration: 0.9)),
        KeyFrame(offset: -10, animation: .easeInOut(duration: 0.75)),
        KeyFrame(offset: 0, animation: .easeInOut(duration: 0.6))
    ]

    public var body: some View {
        Dot(diameter: 8, color: color)
            .offset(x: 0, y: keyFrames[index].offset)
            .animation(keyFrames[index].animation
                .delay(delay), value: index)
            .onChange(of: date) { _ in
                up()
            }
            .onAppear {
                up()
            }
    }

    func up() {
        index += 1 % keyFrames.count

        if index == 0 || index == 3 {
            index = 1
        }
    }
}

В этом примере мы применили ступенчатую неинтерактивную анимацию. Она зависит от параметра — это видно по модификатору onChange, в который передаётся параметр date.

Как подружить SwiftUI и UIKit

На WWDC19 говорили, что между компонентами UIKit и SwiftUI существует поддержка обратной совместимости. Это значит, что можно пользоваться «специальной обёрткой», которая даёт использовать самописные компоненты UIKit в среде SwiftUI.

Чтобы воплотить это в жизнь, нужно применить UIViewRepresentable. Для этого необходимо реализовать два обязательных метода:

  • дать возможность создавать компонент UIView на основе поступившего контекста;

  • обновлять этот элемент.

Но есть ещё необязательный метод (dismantleView) при откреплении элемента от иерархии элементов.

public protocol UIViewRepresentable : View where Self.Body == Never {

    associatedtype UIViewType : UIView

    @MainActor func makeUIView(context: Self.Context) -> Self.UIViewType

    @MainActor func updateUIView(_ uiView: Self.UIViewType, context: Self.Context)

    @MainActor static func dismantleUIView(_ uiView: Self.UIViewType, coordinator: Self.Coordinator)

    associatedtype Coordinator = Void

    @MainActor func makeCoordinator() -> Self.Coordinator

    @available(iOS 16.0, tvOS 16.0, *)
    @MainActor func sizeThatFits(_ proposal: ProposedViewSize, uiView: Self.UIViewType, context: Self.Context) -> CGSize?

    typealias Context = UIViewRepresentableContext<Self>
}

Реализовав этот протокол, мы можем использовать компоненты UIKit в декларативной парадигме.

Осталось реализовать makeUIView и updateUIView, чтобы элемент UIKit мог отображаться и обновляться в рамках жизненного цикла SwiftUI.

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

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

Ещё нам понадобится sizeThatFits, который представили на WWDC2022. Эта функция позволяет определять размер компонента для его использования в рамках самописных контейнеров.

Когда мы реализовали компонент с помощью ViewRepresentable, мы смогли передать сюда компонент UIKit и использовать его в вёрстке SwiftUI. Это нужно, если нам потребуются компоненты UIKit, которые ещё не реализованы в SwiftUI системе.

Мы подружили UIKit с SwiftUI. А можно ли наоборот? Можно.

Если мы реализуем навигацию не через рекомендуемые NavigationLink, а через UINavigationController + UIHostingController, где

UIHostingController<Content>: UIViewController where Content : View

Так мы и попадаем в мир UIKit с компонентами SwiftUI.

А преданные фанаты Storyboard могут использовать этот контроллер, чтобы добавить элементы SwiftUI в UIKit.

Одно кольцо, чтоб править всеми

Ладно-ладно, не кольцо, а компонент. Получается, что для совмещения старого и нового подходов мы используем один и тот же компонент. Как так вышло? Всё дело в layout render loop и способе его отработки.

Раньше мы ориентировались на события, поступающие в Layout Engine: External/Internal Changes. Это новые элементы — UIView и его производные — и изменения, связанные с Constraints. Это приводило к перечислению интерфейса с последующим его отображением.

В случае SwiftUI это выглядит иначе — здесь нет Constraints и отдельных UIView. Триггер для обновления элементов — state и environment, которые в процессе работы могут изменяться. Из-за этого интерфейс перерисовывается.

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

Теперь кажется, что SwiftUI и UIKit между собой совсем не связаны. Но это не так. Докажем примером.

При нажатии на текст "Hello, world!" мы попадём на другой экран. Так применение рукописной UIKit навигации не отличается от навигации SwiftUI.

Для изображения элементов на экране здесь использована CALayer. В комбинации с UIView это позволило сохранить и лаконично интегрировать цикл отрисовки в компоненты: в UIHostingController — наследника UIViewController.

Передача информации между view — context

Библиотека для вёрстки на основе декларативного синтаксиса на языке Swift — проприетарная. Мы также не можем увидеть, как взаимодействуют между собой элементы экрана, поскольку нет доступа к исходному коду.

Давайте разбираться. Возьмём пример кода SDK Flutter.

В 2011 году Google представил замену, как они говорили, Javascript — язык Dart. Он обладал высокой производительностью получаемых программ в браузерах, смартфонах, компьютерах и серверах.

В феврале 2018 года Google выпустила Dart 2.0. Язык стал оптимизирован для клиентской части и лёг в основу UI-фреймворка под названием Flutter. Его основной задачей назвали возможность создавать приложения для разных устройств: телефона, компьютера или веб-браузера.

Сегодня Flutter поддерживает Linux, macOS, Windows в виде десктопных приложений, веб-приложения для браузеров и мобильные приложения для Android и iOS/iPadOS из одного исходного кода.

Итак, как формируются данные для отображения на экране во SDK Flutter (everything is a widget):

///  * [StatefulWidget] and [State], for widgets that can build differently
///    several times over their lifetime.
///  * [InheritedWidget], for widgets that introduce ambient state that can
///    be read by descendant widgets.
///  * [StatelessWidget], for widgets that always build the same way given a
///    particular configuration and ambient state.

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

[StatefulWidget] и создаваемый в нём [State] позволяют нам сохранять внутреннее состояние виджета, в том числе данные, необходимые для работы и отображения. Например, поступившие извне начальные данные для элемента, которые могут впоследствии измениться.

В SwiftUI для тех же целей — для хранения состояния внутри объекта или элемента — мы используем «обёртки» свойств (propertyWrapper).

В процессе жизненного цикла из данных Widget создаётся Element. Элемент содержит информацию о виджете, родителе, детях, своих размерах и объекте отображения (RenderObject).

Widget — неизменяемый объект даже при создании Element. За счёт этого Widget получается легковесным и спокойно перемещается по дереву виджетов.

В процессе обработки build функции виджета получается три связанных между собой дерева. При изменении его части можно внести правки только в часть дерева, и эту задачу решит SDK Flutter.

Контекст содержит обширную информацию по иерархии элементов, с которыми мы можем взаимодействовать. 

Но в SwiftUI этот контекст от нас спрятан. У нас есть только ограниченный доступ к возможности на него влиять. 

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

Начиная с iOS16 возможности по формированию своих элементов расширились за счёт создания CustomLayout — подписи написанных элементов под Layout протокол. 

Перейдём к контексту, который мы получаем во время работы с View.

/// Contextual information about the state of the system that you use to create
/// and update your UIKit view.
///
/// A ``UIViewRepresentableContext`` structure contains details about the
/// current state of the system. When creating and updating your view, the
/// system creates one of these structures and passes it to the appropriate
/// method of your custom ``UIViewRepresentable`` instance. Use the information
/// in this structure to configure your view. For example, use the provided
/// environment values to configure the appearance of your view. Don't create
/// this structure yourself.
         typealias Context = UIViewRepresentableContext<Self>

Для отслеживания параметров используем UIViewRepresentable. Он позволяет получить доступ к некоторым параметрам родительской View.

struct UIViewWithBackgroundColor: UIViewRepresentable {
    @State var color: UIColor
    
    func makeUIView(context: Context) -> some UIView {
        let view = UIView(frame: .zero)
        view.backgroundColor = .brown
        return view
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        uiView.backgroundColor = color
    }
}

Эти методы предоставляют доступ к контексту, который состоит из трех полей:

  • coordinator,

  • transaction,

  • environment.

Coordinator позволяет организовывать передачу информации между компонентами SwiftUI и UIKit. По сути координатор — это делегат компонентов UIKit.

Для этого мы должны реализовать объект-делегат и с его помощью создать координатор. Так мы свяжем UIKit компонент и SwiftUI вёрстку.

struct TextFieldView: UIViewRepresentable {
    let placeholder: String
    @Binding var text: String
    
    typealias Coordinator = TextfieldCoordinator
    
    func makeUIView(context: Context) -> UITextField {
        let view = UITextField(frame: .zero)
        
        view.text = text
        view.placeholder = placeholder
        view.delegate = context.coordinator
        
        return view
    }
    
    func updateUIView(_ uiView: UITextField, context: Context) {
        uiView.text = text
    }
    
    func makeCoordinator() -> Coordinator {
        TextfieldCoordinator(self)
    }
    
    final class TextfieldCoordinator: NSObject, UITextFieldDelegate {
        var textfield: TextFieldView
        
        init(_ textfield: TextFieldView) {
            self.textfield = textfield
        }
        
        func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
            if string.isEmpty  {
                return true
            }
            
            let regex = "[^a-z]"
            return string.lowercased().range(of: regex, options: .regularExpression) == nil
        }
    }
}

А потом просто интегрируем в код:

struct TextFieldScreenView: View {
    @State var input: String = ""
    
    var body: some View {
        TextFieldView(placeholder: "placeholder", text: $input)
    }
}

Поле transaction позволяет реализовывать анимацию для компонентов UIKit из компонентов SwiftUI. Так мы получаем возможность вызывать анимацию через .withTransaction и .withAnimation прямо из View, передавая её через контекст.

Поле environment позволяет получить доступ к переменным окружения, которые мы завели и передали в родительское View.

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

Вернёмся к CustomLayout.

struct CustomContainer: Layout {
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        .zero
    }
    
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        
    }
}

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

Subview, которые вы увидели в примере выше, — это аналоги иерархии моделей у Layout Engine, применяемой в UIKit. В ней хранится легковесная информация, которая позволяет быстро выстроить иерархию компонентов на экране и обновить эти данные.

В отличие от SDK Flutter, сформированное дерево перестроится в зависимости от вычисленного раньше body. Если же какие-то данные изменяются, то происходит перерасчет body и body его «детей».

При этом, если «ребенок», входящий в изменённое View, не поменял своего состояния, то его body уже вычислена — пересчитывать её не требуется.

К чему это всё идет

За время развития мобильных технологий языки программирования и решения на их базе эволюционировали. Некоторые остались в рамках нативных решений, например UIKit/SwiftUI и xml у android-разработчиков. Некоторые — выросли из других направлений — Flutter, к примеру.

При этом все они преследуют одну цель — помогать легко реализовывать красивые масштабируемые продукты. А развитие инструментов на базе этих языков призвано упростить разработку и снизить порог входа для новых разработчиков.

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

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

Как же так, скажете вы, ведь уже есть no-code решения? Действительно, уже существуют технологии, позволяющие разрабатывать продукты без кода. Но они ограничены в применении и способны решать далеко не все задачи.

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

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


  1. white1ion
    17.11.2022 17:14
    +1

    Спасибо, классная статья!
    Жду compose multiplatform))