Вместо предисловия

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

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

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


Изменилось ли мое мнение? Безусловно! Теперь я однозначно выделяю SwiftUI в своем списке фаворитов. Стоит отметить, что при его использовании то и дело возникают косяки, но в целом это не проблема SwiftUI, а скорее отдельных View, либо отсутствие необходимых данных т.к. Apple все же бывает скудна до документации там, где это необходимо.

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

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

Приложение

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

В нем используется классический набор: CoreData, UserDefault и SwiftUI.

Состав публикации

  • Вычисляемые свойства

  • Инициализация оболочек(оберток) свойств

  • Собственные оболочки свойств

  • Динамический предикат (NSPredicate)

  • @ViewBuilder

  • Optional(nil) != nil

  • Пару слов о NavigationView

Вычисляемые свойства

struct MyView: View {
    
    @State var isDisplacedText: Bool = false
    @State var isDisplacedRectangle: Bool = false
    
    var body: some View {
        VStack{
            Text("Текст")
                .offset(y: isDisplacedText ? 100 : 0)
            Rectangle()
                .offset(y: isDisplacedRectangle ? 100 : 0)
        }
        .padding(.top, isDisplacedText ? 100 : 0)
        .padding(.top, isDisplacedRectangle ? 100 : 0)
    }

}

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

struct MyView: View {
    
    @State var isDisplacedText: Bool = false
    @State var isDisplacedRectangle: Bool = false
    
    private var offsetText: CGFloat {
        isDisplacedText ? 100 : 0
    }
    
    private var offsetRectangle: CGFloat {
        isDisplacedRectangle ? 100 : 0
    }
    
    private var paddingVstask: CGFloat {
        (isDisplacedRectangle ? 100 : 0) + (isDisplacedRectangle ? 100 : 0)
    }
    
    var body: some View {
        VStack{
            Text("Текст")
                .offset(y: offsetText)
            Rectangle()
                .offset(y: isDisplacedRectangle ? 100 : 0)
        }
        .padding(.top, paddingVstask)
    }

}

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

struct MyView: View {
    
    @State var isDisplacedText: Bool = false
    @State var isDisplacedRectangle: Bool = false
    
    private var offsetText: CGFloat {
        isDisplacedText ? 100 : 0
    }
    
    private var offsetRectangle: CGFloat {
        isDisplacedRectangle ? 100 : 0
    }
    
    private var paddingVstask: CGFloat { self.returnPaddingVstask() }
    
    var body: some View {
        VStack{
            Text("Текст")
                .offset(y: offsetText)
            Rectangle()
                .offset(y: isDisplacedRectangle ? 100 : 0)
        }
        .padding(.top, paddingVstask)
    }
    
    private func returnPaddingVstask() -> CGFloat {
        (isDisplacedRectangle ? 100 : 0) + (isDisplacedRectangle ? 100 : 0)
    }

}

Код приобретает более читаемый вид и делит View на логические блоки.

Инициализация оболочек свойств

Рассмотрим View

struct MyView: View {

    @State var isShowed: Bool = false

    var body: some View {
        //...код
    }
  
}

Согласно трактовке Apple (по возможности отказаться от инициализаторов и использовать .onApear) переодически он (инициализатор) бывает необходим и отказаться от его реализации не представляется возможным (ниже будет более необходимая ситуация, требующая инициализатор). Собственно посмотрим код

struct MyView: View {

    @State var isShowed: Bool
    
    init(isShowed: Bool){
    	self.isShowed = isShowed
    }

    var body: some View {
        //...код
    }
  
}

сейчас проблем не возникает, но такая же вариация инициализатора с @Binding не получится, чтобы понять почему вернемся к @State и запишем инициалиацию иначе

struct MyView: View {

    @State var isShowed: Bool
    
    init(isShowed: Bool){
    	self._isShowed = State(initialValue: isShowed)
    }
  
  	//Более подробно
    init(isShowed: Bool){
      let stateValue: State<Bool> = State<Bool>(initialValue: isShowed)
    	self._isShowed = stateValue
    }

    var body: some View {
        //...код
    }
  
}

@State - это оболочка свойства, представленная State<>.

@Binding - представлен Binding<>, соответственно его инициализация доступна таким же образом как и выше

struct MyFirstView: View {

    @State var isShowed: Bool
    
    init(isShowed: Bool){
        self._isShowed = State(initialValue: isShowed)
    }

    var body: some View {
        MySecondView($isShowed)
    }
  
}

struct MySecondView: View {

    @Binding var isShowed: Bool
    
    init(_ isShowed: Binding<Bool>){
        self._isShowed = isShowed
    }

    var body: some View {
        //...Код
    }
  
}

Собственные оболочки свойств

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

@propertyWrapper struct File: DynamicProperty {
    
    private let string: State<String>
    private let path: URL
    
    var wrappedValue: String {
        get {
            string.wrappedValue
        }
        nonmutating set { setWrappedValue(newValue) }
    }
    
    public var projectedValue: Binding<String> {
        Binding(
            get: { string.wrappedValue },
            set: { wrappedValue = $0 }
        )
     }
    
    private func setWrappedValue(_ value: String){
        print("принял \(value)")
        do {
            try value.write(to: path, atomically: true, encoding: .utf8)
						self.string.wrappedValue = value
        } catch {
            print("Ошибка записи")
        }
    }
    
    init(wrappedValue fileName: String) {
        self.path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName)
        self.string = State(initialValue: (try? String(contentsOf: self.path)) ?? "пусто")
    }
}

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

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

struct MyView: View {
    
    @File var file = "file.txt"
    
    var body: some View {
        VStack{
            Text(file)
            TextField("", text: $file)
            
        }
        Button("Кнопка") {
            file = "новое значение"
        }
    }
}

projectedValue, если выражать мысли простым языком, необходим для Binding связок

wrappedValue, то к чему мы собственно обращаемся и во что записываем при использовании

wrappedValue в инициализаторе, дает нам удобную инициализацию, но можно конечно сипользовать оболочки иначе

//...

    init(name fileName: String) {
        self.path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent(fileName)
        self.string = State(initialValue: (try? String(contentsOf: self.path)) ?? "пусто")
    }

///...

@File(name: "file.txt") var doc

DynamicProperty протокол, который позволяет SwiftUI перерисовывать представление при изменении динамических свойств.

Можно записать еще короче и вместо State<> использовать @State в нашей оболочке. При таком использовании мы лишимся возможности использовать константу, но и сможем избавится от метода в немутируещем сеттере.

Динамический предикат (NSPredicate)

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

struct MyApp: App {

    let coreData = CoreDataController.shared

    var body: some Scene {
        WindowGroup {
            MyView()
              .environment(\.managedObjectContext, coreData.container.viewContext)
        }
    }

}

struct MyView: View {

    @Environment(\.managedObjectContext)
    private var viewContext

    @FetchRequest( sortDescriptors:[], NSPredicate() )
    private var myEntities: FetchedResults<MyEntity>

    var body: some View {
        //...код
    }
  
}

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

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

struct MyView: View {

    @Environment(\.managedObjectContext)
    private var viewContext

    private var myEntitiesRequest : FetchRequest<MyEntity>
    var myEntities : FetchedResults<MyEntity> {
    		myEntitiesRequest.wrappedValue
    }
    
    init(predicate: NSPredicate) {
        self.transactionRequest = FetchRequest(
            sortDescriptors: [],
            predicate: predicate
        )
    }

     var body: some View {
     		//...код
     }
 }

Необходимо просто использовать то, что мы разобрали ранее.

Собственно теперь мы можем в одном View генерировать предикат, а в другом его использовать.

@ViewBuilder

Посмотрите на код ниже

struct MyView: View {
    var body: some View {
        VStack{
            Rectangle()
            Text("")
        }
    }
}

Внутри Vstack мы размещаем View. Как сделать так же?

Зайдем с другой стороны.

func example(value: String, closure: () -> Void){
    print(value)
    closure()
}

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

example(value: "значение", closure: { print("замыкание") } )

Теперь сократим.

example(value: "значение"){
    print("замыкание")
}

Если убрать value или присвоить значение по умолчанию, то можно написать вот так.

example(){
    print("замыкание")
}

Ну и конечно сократим скобки.

example{
    print("замыкание")
}

Знакомая картина? Замыкание которое передается последним параметром, можно вынести за скобки. Замыкания представляют собой тип данных и их можно хранить в свойствах типов (с указанием @escaping), передавать в инициализаторе и т.д.

@ViewBuilder же возвращает View и тоже является замыканием. Соответственно реализация достаточно простая, нам нужно только определить получаемый тип данных, как тип соответствующий протоколу View

struct MyStack<Content>: View where Content: View {
    
    let content: () -> Content
    
    init(@ViewBuilder content: @escaping () -> Content) {
        self.content = content
    }
    var body: some View {
        VStack{
            Rectangle()
                .frame(height: 100)
                .foregroundColor(.gray)
            Spacer()
            
            content() //Все, что получает ViewBuilder
          
            Spacer()
            Rectangle()
                .frame(height: 100)
                .foregroundColor(.gray)
        }
        .background(Color.blue)
        .ignoresSafeArea()
    }
}

И использование

struct MyView: View {
    var body: some View {
        MyStack{
            Text("Текст")
                .foregroundColor(.white)
        }
    }
}

На выходе получим

Optional(nil) != nil

Вообще - это не относится к моему приложению, но относится к публикации. А так как приложение является причиной появляния публикации, одно другому не противоречит.

Так сложилось, что на днях я читал публикацию и там описывается интересная особенность SwiftUI. Она заключается в том, что при инициализации View с получаемыми View, в инициализацию может пройти Optional т.к. согласно документации Apple, Optional соответствут View. Следовательно Optional(nil) может так или иначе пройти в инициализацию и не дать провести проверку на nil т.к. Optional(nil) не равно nil, но при этом соответствует View. В той же публикации приведен участок кода как пример

Код из публикации с демонстрацией проблемы
struct ContentView: View {
    var body: some View {
        let v: Optional<Optional<Text>> = .some(nil)
        VStack{
            Helper.ultraView(title: "чук рулит")
            Helper.ultraView(title: "гек норм", description: "потому что брат")
            
            UltraView(
              title: Text("текст"),
              description: v)
        }
        .padding(.horizontal, 10)
    }
}


struct UltraView<T1: View, T2: View>: View {
    let title: T1
    let description: T2? // вьюхи может и не быть же, верно?

      // базовый конструктор
    init(title: T1, description: T2?) {
        self.title = title
        self.description = description
    }

      // сокращенный вариант конструктора, когда второго элемента нет
    init(title: T1) where T2 == EmptyView {
        self.title = title
        self.description = nil
    }

    var body: some View {
        let color = description == nil ? Color.blue : Color.green

        VStack {
            title

            if let description = description {
                description
            }
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color) // маленький хелпер для рисования рамки
    }
}

struct Helper {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView(
          title: Text(title),
          description: description.map { Text($0).font(.footnote) })
              //                       ^ просто трансформируем опционал в Text
    }
}

Мне стало интересно как работать с этой ситуацией и мне пришло в голову вот такое решение

extension Optional where Wrapped: View {
    var isNil: Bool {
        if ((self as AnyObject) as? NSNull) == nil {
            return false
        } else {
            return true
        }
    }
}

Объясню что происходит. Несмотря на то, что SwiftUI работает с типами значений. View все же является протоколом. Как то ограничить class на соответствие протоколу нельзя, следовательно View можно привести к AnyObject, которому соответствуют все классы и к которому нельзя привести Optional. При преобразовании Optional(nil) к AnyObject мы получим null, который можно привести к NSNull, следовательно если наш View (потенциальный Optional(nil)) можно привести к NSNull через прокладку в виде AnyObject, то это nil. Довольно костыльное решение, но оно работает.

init(title: T1, description: T2?) {
	self.title = title
	self.description = description.isNil ? nil : description
}

Пару слов о NavigationView

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

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

See you later...

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


  1. s21462
    12.01.2022 09:03

    Спасибо за статью.

    но представьте сложное View, где много логики

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


    1. Gummilion
      13.01.2022 06:38

      Видимо, в Apple решили сделать свой аналог React, но на Свифте. А использовать XAML - ну это же как в Андроиде будет, как можно!