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

Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на 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?! }
Время собирать камни
Выводы из сей басни таковы, мой маленький друг:
Писать свои вью с учетом опциональности входящих параметров (тоже вью) бесполезно. Максимум, что получится - вложенные опционалы
Решить проблему может использование кложур - они могут быть опциональны. И они сами по себе точно не вью. Но это требует соблюдения контракта - более сложный синтаксис и доверие, что достаточно проверить только первый уровень матрешки.
Можно использовать свифтовое поведение - приведение к
Optional<Never>. Однако, оно тоже разворачивает только первый уровень вложенных опционалов в случае сложной структуры вьюх.Можно реализовать некрасивое, но рабочее всегда решение - определять, что самая вложенная вью - не опционал. (см. решение Чука с
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
TandUare any two types, thenOptional<T>.none is Optional<U> == true
Успешным будет не только каст к Never?, но и к любому другому опционалу.
let i: Int? = nil i is String? // <- тут что будет
Основной вопрос, поднятый в статье, так и не нашел пока пока объяснения
