Как стать автором
Обновить

SwiftUI: Как Чук и Гек искали nil

Время на прочтение8 мин
Количество просмотров3K

Однажды в тайге

Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на SwiftUI и столкнулись с неведомым! Как Optional притворялся View и к чему это привело.

Ничто не предвещало...

Однажды Чук и Гек решили сделать свой пет-проект и чтобы дело шло быстрее, поделили обязанности - Гек делал кастомные вьюхи, а Чук собирал из них экраны.
Как то понадобилась одна простая штука: вью, состоящая из двух элементов, расположенных один над другим, второй - опционален. Если второго нет, то рамка вокруг вьюхи должна быть зеленая, а если есть - синяя.
Посидел Гек, подумал и выдал такое, с использованием дженериков:

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) // маленький хелпер для рисования рамки
    }
}

Протестировал:

@ViewBuilder func geksTest() -> some View {
    UltraView(title: Text("чук рулит"))
    UltraView(title: Text("гек норм"), description: Text("потому что брат").font(.footnote))
}
Результат теста Гека
Результат теста Гека

С чувством выполненного долга, отдал код брату, пошел на кухню ставить самовар. Сидит, кайфует. И тут слышит, Чук зовет:
- "Эй, брат, фигня какая-то, ты какулю сделал!"

Гек откладывает сушку и идет к брату и видит:

- "Ну", - говорит - "зачем ты пустую вью передал? .... Хотя, где тогда спейсинг? А ну, покажи-ка, брат, код!"

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

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

@ViewBuilder func chuksTest() -> some View {
    Helper.ultraView(title: "чук рулит")
    Helper.ultraView(title: "гек норм", description: "потому что брат")
}

Гек схватился за сердце:
- "Как ты это сделал? Это же противозаконно! Как ты засунул Optional в дженерик, который требует View ?"

После двух чашек успокоительного для Гека братья обнаружили, что внезапно:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
extension Optional : View where Wrapped : View {
    public typealias Body = Never
}

После этого возник вопрос...

Что делать?

Очевидно, что поведение заложено разработчиками SwiftUI и этого не изменить. Дамп выдает примерно такое:

Optional<Optional<Text>>:
	- some: Optional<Text>:
  	- none

"Но что же делать"? - рассуждал Гек. - "Изначальная концепция разваливается из-за того, что мы можем передать nil и он влезет по констрейтам дженерика, а моя вьюха нужна и должна предсказуемо работать. Просто проверить, что это опционал нельзя - к какому типу Optional приводить тип, если он дженерик, а мы только знаем, что T2 может быть опционалом? Придется искать обходные пути."

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

Сказано - сделано:

struct UltraView<T1: View, T2: View>: View {
    let title: T1
    let description: (() -> T2)?

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

  	// этот конструктор - для сокращенных записей, 
  	// когда не опциональная вьюха и можно не оборачивать в скобки
    init(title: T1, description: @escaping @autoclosure () -> 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 { str in { Text(str).font(.footnote) } })
    }
}

Работает! Однако, как говорится, есть нюансы - если кложура вернет в свою очередь опционал, нам это не особо поможет :-(

Братья стали копать и Гек выдал такой вариант:

protocol OptionalType {
    var isNil: Bool { get }
}

extension Optional: OptionalType {
    var isNil: Bool {
        if self == nil {
            return true
        } else {
            // рекурсивно ищем, потому что вложенность может
            return (self! as? OptionalType)?.isNil ?? false
        }
    }
}

После рефакторинга получилось вот так:

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 = !hasDescription ? Color.blue : Color.green

        VStack{
            title
          
          	// бессмысленно проверять на nil, по понятным причинам.
          	// однако вью и так не отрисуется и места не займет и спейсинг не появится
            description 
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

  	// немного черной магии
    var hasDescription: Bool {
        guard let opt = description as? OptionalType else { return true }

        return !opt.isNil
    }
}

Проверили - работает! Ура! Даже если получаются вложенные вьюхи. Однако, что-то смущало Гека... И задумчивый, он пошел спать.

Гештальт Гека

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

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

...

Проснулся Чук от неразборчивого бомотанья Гека. Он встал, пошел на кухню и увидел брата, уставившегося красными глазами в экран и приговаривающего:
- "Как?! Как это работает?!"
- "Брат, ты чего?" - спросил Чук.

Гек молча показал на экран, где был выделен код:

var hasDescription: Bool {
		!(description is Never?) // WHY?!
}

Время собирать камни

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

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

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

  3. Можно использовать свифтовое поведение - приведение к Optional<Never>. Однако, оно тоже разворачивает только первый уровень вложенных опционалов в случае сложной структуры вьюх.

  4. Можно реализовать некрасивое, но рабочее всегда решение - определять, что самая вложенная вью - не опционал. (см. решение Чука с OptionalType)

Вообще, в зависимости от целей, придется решать - нужно определять только входной параметр как nil или всю потенциальную иерархию.

Пример почти синтетический, но встретился в реальном проекте. В конце мы остановились пока на варианте 3 - как говорится "swift only solution", но с ремаркой в коде, что черт его знает, не изменится ли это в будущем. Вероятно, прийдем к варианту 4.

Однако до сих пор мы маемся вопросом, почему каст к Optional<Never> работает с любым типом и успешен только в случае nil. Мы пришли к выводу, что это какая-то особенность компилятора. Никаких материалов навскидку не нашли. Однако, если кто-то сможет подсказать, где про это почитать, буду признательна.

Полный пример для Swift Playground
//: A UIKit based Playground for presenting user interface

import SwiftUI
import PlaygroundSupport

// helper

extension View {
    func border(_ color: Color) -> some View {
        background(Color.white)
            .padding(1)
            .background(color)
    }
}

// base sample

struct UltraView1<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 Helper1 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView1(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// fix

// closures

struct UltraView2<T1: View, T2: View>: View {
    let title: T1
    let description: (() -> T2)?

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

    init(title: T1, description: @escaping @autoclosure () -> 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 Helper2 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView2(title: Text(title),
                   description: description
                    .map { str in { Text(str).font(.footnote) } })
    }
}

// use protocol

protocol OptionalType {
    var isNil: Bool { get }
}

extension Optional: OptionalType {
    var isNil: Bool {
        if self == nil {
            return true
        } else {
            // recursive
            return (self! as? OptionalType)?.isNil ?? false
        }
    }
}

struct UltraView3<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 = !hasDescription ? Color.blue : Color.green

        VStack{
            title
            description
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

    var hasDescription: Bool {
        guard let opt = description as? OptionalType else { return true }

        return !opt.isNil
    }
}

struct Helper3 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView3(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// only swift

struct UltraView4<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 = !hasDescription ? Color.blue : Color.green

        VStack {
            title
            description
        }
        .frame(maxWidth: .infinity)
        .padding()
        .border(color)
    }

    var hasDescription: Bool {
        !(description is Never?) // WHY?!
    }
}

struct Helper4 {
    static func ultraView(title: String, description: String? = nil) -> some View {
        UltraView4(title: Text(title), description: description.map { Text($0).font(.footnote) })
    }
}

// preview

// переключение примеров производится изменением тайпалиасов ниже
typealias Helper = Helper1
typealias UltraView = UltraView1

@ViewBuilder func geksTest() -> some View {
    UltraView(title: Text("чук рулит"))
    UltraView(title: Text("гек норм"), description: Text("потому что брат").font(.footnote))
}

@ViewBuilder func chuksTest() -> some View {
    Helper.ultraView(title: "чук рулит")
    Helper.ultraView(title: "гек норм", description: "потому что брат")
}

struct Preview: View {
    var body: some View {
        VStack(spacing: 20) {
            geksTest()

            Divider()

            chuksTest()
        }
        .padding()
    }
}

PlaygroundPage.current.setLiveView(Preview())

/// WHY? Компилятор считает все `.none` - это отдельный тип, который никогда не используется (`Never`)? Но `Optional` - дженерик с конкретным типом, а не `Never`. Или, может, это баг компилятора? Или `nil` просто может кастоваться к любому типу, в том числе и `Never`? Но ведь `T2` во время компиляции заведомо не `Never`

// MARK: -

let an: Int? = nil
let bn: Int? = 1

an is Never?
bn is Never?

UPD: Спасибо @Tyranronза наводку: https://github.com/apple/swift/blob/main/docs/DynamicCasting.md#optionals

Nil Casting: if T and U are any two types, then Optional<T>.none is Optional<U> == true

Успешным будет не только каст к Never?, но и к любому другому опционалу.

let i: Int? = nil
i is String? // <- тут что будет

Основной вопрос, поднятый в статье, так и не нашел пока пока объяснения

Теги:
Хабы:
Всего голосов 4: ↑4 и ↓0+4
Комментарии23

Публикации

Истории

Работа

iOS разработчик
13 вакансий
Swift разработчик
13 вакансий

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань