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

от автора

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

Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на 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? // <- тут что будет

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


ссылка на оригинал статьи https://habr.com/ru/post/597933/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *