Привет! Меня зовут Ренат, я разрабатываю сервис по аналитике подписок в iOS – Apphud.


Как вы знаете, Apple на WWDC 2019 представила свой новый фреймворк SwiftUI, который призван в будущем заменить (или нет?) привычный нам UIKit. SwiftUI позволяет описывать интерфейс приложений в декларативном стиле и сильно сокращает количество кода.


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


Перед началом работы


Для работы со SwiftUI необходимо скачать Xсode 11 Beta. Вы также должны быть зарегистрированным разработчиком Apple. Иметь последнюю macOS Catalina желательно, но не обязательно. Без нее не будет доступен Canvas.


Итак, в Xсode 11 Beta создайте новый проект и убедитесь, что стоит галочка “Use SwiftUI”.


Вопросы и ответы


Куда подевался Interface Builder?


Для SwiftUI теперь не нужен Interface Builder – ему на смену пришел Canvas – интерактивный редактор интерфейса, который тесно связан с кодом. При написании кода автоматически генерируется его визуальная часть в canvas и наоборот. Очень удобно, а главное безопасно. Теперь ваше приложение не будет падать из-за того, что вы забыли обновить связь @IBOutlet с переменной. В данной статье мы не будем затрагивать canvas, рассмотрим только код.


Изменился ли запуск приложения?


Да, теперь начальным объектом в интерфейсе приложения является не UIWindow, а новый класс UIScene (либо его наследник UIWindowScene). И уже к сцене добавляется окно. Эти изменения касаются не только SwiftUI, но и iOS 13 в целом.


При создании проекта вы увидите файлы AppDelegate, SceneDelegate и ContentView. SceneDelegate – делегат класса UIWindowScene, используется для управления сценами в приложении. Сильно напоминает AppDelegate.


Класс SceneDelegate указывается в Info.plist
Класс SceneDelegate указывается в Info.plist


В методе делегата scene: willConnectTo: options: создается окно и рутовый UIHostingController, который содержит в себе ContentView. ContentView – и есть наша "домашняя" страница. Вся разработка будет вестись в этом классе.


Чем View отличается от UIView?


Открыв ContentView.swift, вы увидите объявление контейнера ContentView. Как вы уже поняли, в SwiftUI нет привычных методов viewDidLoad или viewDidAppear. Основой экранов здесь является не UIViewController, а View. Первое, что стоит заметить, ContentView – это структура (struct), которая принимает протокол View. Да, View теперь стал протоколом, причем очень простым. Единственный метод, который вы должны реализовать в ContentView – это описать переменную body. Все ваши subview и кастомные view должны принимать протокол View, то есть должны иметь переменную body.


Что такое body?


Body – это непосредственно наш контейнер, куда добавляются все остальные subview. Это чем-то напоминает body в html странице, где html страница – это ContentView. Body всегда должен иметь ровно один потомок, причем любого класса, принимающего протокол View.


struct ContentView: View {     
     var body: some View {         
          Text("Hello, world!")     
     }
}

Opaque return types или что такое some?


Конструкция some TypeName – это нововведение Swift 5.1, которое называется opaque return type. Оно используется для случаев, когда нам не важно, какой конкретно объект вернуть, главное, чтобы он поддерживал указанный тип, в данном случае протокол View.


Если бы мы просто написали var body: View, то это бы означало, что мы должны вернуть именно View. Класс Any тоже не подходит, так как нам пришлось бы выполнить операцию приведения типа (с помощью оператора as!). Поэтому придумали специальное слово some перед названием протокола для обозначения opaque return type. Вместо View мы можем вернуть Text, Image, VStack – что угодно, так как все они поддерживают протокол View. Но должен быть ровно один элемент: при попытке вернуть больше одной View компилятор выдаст ошибку.



Ошибка компилирования при попытке вернуть в body более одного элемента


Что за синтаксис внутри скобок и где addSubview?


В Swift 5.1 появилась возможность группировать объекты в нечто единое целое в декларативном стиле. Это похоже на массив внутри closure-блока, однако элементы перечисляются с новой строки без запятых и return. Данный механизм назвали Function Builder.


Это нашло широкое применение в SwiftUI. На основе Function Builder сделали ViewBuilder – декларативный конструктор интерфейса. Используя ViewBuilder нам больше не нужно писать addSubview для каждого элемента – достаточно перечислить все View с новой строки внутри closure-блока. SwiftUI сам добавит и сгруппирует элементы в более сложный родительский контейнер.


@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
@_functionBuilder public struct ViewBuilder {

    /// Builds an empty view from an block containing no statements, `{ }`.
    public static func buildBlock() -> EmptyView

    /// Passes a single view written as a child view (e..g, `{ Text("Hello") }`) through
    /// unmodified.
    public static func buildBlock<Content>(_ content: Content) -> Content where Content : View
}

Объявление ViewBuilder в SwiftUI фреймворке


Как добавлять UILabel, UIImageView и другие элементы?


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


var body: some View {
     VStack{
          Text("World Time").font(.system(size: 30))
          Text("Yet another subtitle").font(.system(size: 20))
     }
}

Однако не все контролы и View имеют свои аналоги в SwiftUI. Вот неполный список классов из UIKit и их аналоги:


  • UITableView -> List


  • UICollectionView не имеет аналога


  • UILabel -> Text


  • UITextField -> TextField


  • UIImageView -> Image


  • UINavigationController -> NavigationView


  • UIButton -> Button


  • UIStackView -> HStack / VStack


  • UISwitch -> Toggle


  • UISlider -> Slider


  • UITextView не имеет аналога


  • UIAlertController -> Alert / ActionSheet


  • UISegmentedControl -> SegmentedControl


  • UIStepper -> Stepper


  • UIDatePicker -> DatePicker



Как происходит навигация между экранами?


Роль navigation controller берет на себя специальный NavigationView. Достаточно обернуть ваш код в NavigationView{}. А само действие перехода можно добавить в специальную кнопку NavigationButton, которая пушит условный экран DetailView.


var body: some View {
     NavigationView {
     Text("World Time").font(.system(size: 30))
          NavigationButton(destination: DetailView() {
               Text("Go Detail")
          }
     }
}

Как представлять новые view модально? Это делается, например, с помощью presentation button:


PresentationButton(Text("Present"), destination: DetailView())

Как говорилось выше, body может возвращать не только экземпляр View, но и любой другой класс, принимающий данный протокол. Это дает нам возможность пушить не DetailView, а даже Text или Image!


Как располагать элементы на экране?


Элементы располагаются зависимо друг от друга и могут быть расположены вертикально внутри VStack, горизонтально HStack и друг над другом ZStack. Так же нам доступны ScrollView и ListView. Можно чередовать и использовать совместно эти контейнеры, чтобы получить любую сетку элементов.


Комбинируя контейнеры между собой можно получить довольно крупное дерево с большим количеством вложений. Однако SwiftUI оптимизирован специально для этого, так что глубокая вложенность контейнеров не влияет на производительность. Об этом говорится в видео с wwdc (начиная с 15:32).


var body: some View {
     NavigationView {
          VStack {
               NavigationButton(destination: LargeView(timeString: subtitle)) { Text("See Fullscreen") }
               Text("World Time").font(.system(size: 30))
          }
     }
}

Как показать Navigation Bar?


Объявить NavigationView недостаточно, необходимо указать navigation title и стиль navigation bar’а.


NavigationView {
     VStack{}.navigationBarTitle(Text("World Time"),  displayMode: .inline)
}

Обратите внимание, что функция navigationBarTitle вызывается не у NavigationView, а у ее внутренней View. DisplayMode – параметр, который указывает стиль navigation bar’а: большой или стандартный.


Есть ли аналог метода viewDidLoad?


Если вы хотите выполнить код при инициализации View, вы можете сделать это, добавив функцию onAppear{}. OnAppear можно добавить к любой View, например, к VStack. В данном примере при появлении контейнера на экране выполняется http-запрос к серверу.


struct ContentView : View {
     @State var statusString : String = "World Time"
     var body: some View {
          NavigationView {
               VStack {
                 NavigationButton(destination:DetailView()) {
                     Text("Go Detail")
                 }
                 Text(statusString).font(.system(size: 30))
               }.onAppear {
                     self.loadTime()
                 }
          }
     }
     func loadTime(){
          NetworkService().getTime { (time) in
               if let aTime = time {
                    self.statusString = "\(aTime.date())"
               }
          }
     }
}

Мы вызываем функцию loadTime, которая запрашивает текущее время из сервера и возвращает модель WorldTime. Не будем зацикливаться на классе NetworkService, вы сможете посмотреть весь код, скачав исходники. Ссылка в конце статьи.

Переменная var statusString была вынесена для того, чтобы позже к ней присвоить текущее время. Перед переменной стоит особый атрибут @State. Что он означает?


Property Wrappers или что такое @State?


В Swift 5.1 появились так называемые property wrappers (или property delegates). В SwiftUI property wrappers используются для того, чтобы обновлять или связывать один из параметров view с нашей собственной переменной, например, значение переключателя (Toggle).


Атрибут @State – специальный атрибут, который ставится перед объявлением переменной. Это позволяет нам автоматически отслеживать изменение свойства без дополнительного кода. В примере выше текст "World Time" изменится на текущую дату, как только мы обновим значение statusString .


Для связывания значений (Properties Binding) мы можем указать специальный символ $ перед названием переменной в самом коде:


struct DetailsView: View {
    @State var changeToggle: Bool

    var body: some View {
          Toggle(isOn: $changeToggle) {
              Text("Change Toggle")
          }
    }
}

Изменив положения переключателя, изменится и значение переменной


Property wrappers являются очень важной составляющей SwiftUI, я лишь вскользь упомянул о них. Для более подробного ознакомления с property wrappers посмотрите видео с wwdc здесь (с 37й минуты), здесь (c 12й минуты) и здесь (с 19й минуты).


Как добавлять view в runtime?


Сразу стоит отметить, что добавлять view в произвольное время в прямом смысле слова нельзя. SwiftUI – декларативный фреймворк, который рендерит весь view целиком. Однако можно задавать различные условия внутри body и обновлять состояние view при их изменении. В данном примере используем простейшую связку @State – if с переменной isTimeLoaded.


struct ContentView : View {

    @State var statusString : String = "World Time"
    @State var isTimeLoaded : Bool = false

    var body: some View {
        NavigationView {           
                VStack {
                    if isTimeLoaded {
                       addNavigationButton()    
                    }
                    Text(statusString).font(.system(size: 30)).lineLimit(nil)                              
                }.navigationBarTitle(Text("World Time"), displayMode: .inline)
            }.onAppear { 
                self.loadTime()
            }        
    }

    func addNavigationButton() -> some View {
        NavigationButton(destination: Text("124!!!")) {
            Text("Go Detail")
        }      
    }

    func loadTime(){        
        NetworkService().getTime { (time) in
            if let aTime = time {
                self.statusString = "\(aTime.date().description(with: Locale.current))"
                self.isTimeLoaded = true
            }
        }
    }
}

struct DetailView : View {

    var timeString : String

    var body : some View {
        Text(timeString).font(.system(size: 40)).lineLimit(nil)
    }
}

Кстати, вы заметили, что в функции addNavigationButton() нет слова return? Это еще одно нововведение Swift 5.1 – для функций с одним выражением теперь необязательно писать return. Но можно и писать.


Заключение


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


Логичен вопрос: стоит ли изучать UIKit? Конечно, да. UIKit – основа программирования на iOS и он будет развиваться и дальше. Тем более многие компоненты SwiftUI являются оберткой над UIKit. Ну и пока что нет никаких библиотек, фреймворков, pod-ов для SwiftUI. Все пока придется писать самостоятельно. Так что лучше изучайте оба подхода к разработке – так вы будете более ценным разработчиком.


Исходники проекта вы можете скачать здесь.


Спасибо, что дочитали статью до конца. Надеюсь, она оказалась вам полезной.


Что почитать?


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


  1. ShadowKing
    13.06.2019 16:08

    SwiftUI же еще позволяет писать универсальные приложения для iPad / OS X? Или я что-то путаю?


    1. Ren6 Автор
      13.06.2019 16:20
      +1

      И да, и нет. SwiftUI действительно позволяет писать на все устройства, включая Apple TV и Apple Watch. Однако универсален лишь синтаксис и общие принципы. Универсальным приложение все равно не будет из-за различий в железе и размере экранов. На эту тему есть замечательное видео с wwdc (смотреть с 4й минуты)


      1. ShadowKing
        13.06.2019 17:28

        Вот оно как) Спасибо за разъяснение!


  1. AndreyGerashchenko
    14.06.2019 21:22

    В пункте Как добавлять UILabel, UIImageView и другие элементы? должно быть обернуто в VStack(или HStack). Выше ведь сами написали что не билдится, такой код


    1. Ren6 Автор
      14.06.2019 21:22

      Спасибо за находку! Исправили