Однажды в тайге
Эта таинственная история рассказывает о том, как два брата акробата программиста Чук и Гек начали делать свой проект на 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
T
andU
are any two types, thenOptional<T>.none is Optional<U> == true
Успешным будет не только каст к Never?
, но и к любому другому опционалу.
let i: Int? = nil
i is String? // <- тут что будет
Основной вопрос, поднятый в статье, так и не нашел пока пока объяснения