Каморка сурового программиста, дубль 2

Личная поваренная книга SwiftUI рецептов.

Когда начинаешь разбираться в какой то обширной теме в программировании - количество скачанных проектов, созданных черновиков начинает превышать все мыслимые и немыслимые пределы. А потом всё перемешивается, теряется. Вроде помнил, что ты с этим работал, а где, когда? Я пробовал работать в Playground'ах, но они на мой взгляд не такие стабильные как обычный проект, отваливается подсветка, нет возможности нормально делать Debug. С недавних пор я завел единый проект для исследования SwiftUI, и все небольшие вещи закидываю туда. Это помогает держать все в одном месте, к тому же поиск внутри проекта намного удобней. Хоть SwiftUI и предоставляет Preview для быстрого просмотра View, даже позволяет их отлаживать, все же этого не всегда хватает. Хочется и на устройстве проверить. А если держать все эти View внутри одного проекта - надо при создании новой вьюхи проставлять ее как основную в SceneDelegate, что довольно быстро начинает утомлять. Как было бы круто, если бы мы могли видеть все наши тестовые View при запуске приложения и могли выбрать с чем работать. Фантастика скажете вы? Отнюдь )

Задачу думаю можно решить более чем одним путем. Навскидку - прикрутить Sourcery, но интересно было решить без вспомогательных инструментов.

Итак, что из себя представляет View и её Preview:

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, World!")
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Как мы видим предпросмотр для view обеспечивается структурой, которая имплементирует PreviewProvider, если кто не знал, можно даже внутри одного файла создавать сколь угодно структур/классов, которые будут имплементировать PreviewProvider и в зоне предпросмотра они отобразятся все. Может пригодиться, если хотим разбить наш ContentView_Previews на неколько с разными настройками (хотя можно это же сделать и внутри одной структуры имплементирующей PreviewProvider, но речь не об этом).

Что из себя представляет PreviewProvider - это протокол

/// Produces view previews in Xcode.
///
/// Xcode statically discovers types that conform to `PreviewProvider` and
/// generates previews in the canvas for each provider it discovers.
@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol PreviewProvider : _PreviewProvider {

    /// The type of the previews variable.
    associatedtype Previews : View

    /// Generates a collection of previews.
    ///
    /// Example:
    ///
    ///     struct MyPreviews : PreviewProvider {
    ///         static var previews: some View {
    ///             return Group {
    ///                 GreetingView("Hello"),
    ///                 GreetingView("Guten Tag"),
    ///
    ///                 ForEach(otherGreetings, id: \.self) {
    ///                     GreetingView($0)
    ///                 }
    ///             }
    ///             .previewDevice("iPhone X")
    ///         }
    ///     }
    static var previews: Self.Previews { get }

    /// Returns which platform to run the provider on.
    ///
    /// When `nil`, Xcode infers the platform based on the file the
    /// `PreviewProvider` is defined in. This should only be provided when the
    /// file is in targets that support multiple platforms.
    static var platform: PreviewPlatform? { get }
}

Главное, что можно извлечь из кода, это не простой протокол, а PAT: Protocol with Associated Type, что сразу усложняет дело. Я перепробовал много вариантов как обеспечить нужную функциональность с минимальными усилиями.

Начнем с того как вообще можно подобные вещи делать real time? В Objective-C мы могли делать все что угодно с помощью reflection - получать список всех кассов, исследовать их свойства. В swift это все дело сильно ограничили, и Mirror не даст нам всего необходимого. Поэтому пришлось смотреть в сторону objc_getClassList, это метод из Obj-C рантайма, который позволяет получить список всех классов. К сожалению такого нет для Swift структур, поэтому пришлось обходится тем, что дали.

Разберем решение по частям.

Не получится нормально работать с системным протоколом PreviewProvider из за того, что он PAT, поэтом создадим Erase версию этого протокола

import SwiftUI

protocol PreviewHolder {
    static var anyPreviews: AnyView { get }
    static var name: String { get }
    static var starred: Bool { get }
}

extension PreviewHolder where Self: PreviewProvider {
    static var anyPreviews: AnyView {
        AnyView(previews)
    }

    static var name: String {
        String(describing: self).replacingOccurrences(of: "_Previews", with: "")
    }

    static var starred: Bool { false }
}

1. Как видно я стер тип у previews, создав обертку anyPreviews, которая будет возвращать AnyView. Я не очень люблю такие штуки, потенциальная потеря производительности, но т.к. это не production код, то на это можно закрыть глаза.

2. name - свойство возвращающее имя нашей View, как оно будет отображатсья в списке, учитывая что все Preview имеют автоматом генерируемые имена ViewName_Previews - можно _Previews отрезать.

3. Я добавил свойство starred, т.к. число View будет все увеличиваться, и начиная работать с новым куском кода хочется увидеть его сверху в списке. Это можно сделать переопределив у превьюхи для новой View это свойство, возвращая true.

Сам список выглядит довольно просто.

import SwiftUI

struct PreviewsList: View {

    @State private var starred: [PreviewHolder.Type] = []

    @State private var general: [PreviewHolder.Type] = []

    var body: some View {
        NavigationView {
            List {
                Section(header: Text("Starred")) {
                    SubList(elements: starred)
                }
                Section(header: Text("General")) {
                    SubList(elements: general)
                }
            }.navigationBarTitle("Catalog")
                .onAppear {
                    let sorted = PreviewUtils.parse().sorted(by: { (lhs, rhs) -> Bool in
                        lhs.name < rhs.name
                    })
                    self.starred = sorted.filter { $0.starred }
                    self.general = sorted.filter { !$0.starred }
            }
        }
    }
}

private struct SubList: View {
    var elements: [PreviewHolder.Type]
    var body: some View {
        ForEach(0..<elements.count, id: \.self) { id in
            return NavigationLink(destination: self.elements[id].anyPreviews) {
                Text(self.elements[id].name)
            }
        }
    }
}

struct PreviewsList_Previews: PreviewProvider {
    static var previews: some View {
        PreviewsList()
    }
}

Все View сортируются по имени и разбивается на 2 списка, starred и обычные. Выглядеть все будет примерно так:


Ну и в SceneDelegate просто меняем основную вьюху

let contentView = PreviewsList()

Остался последний момент, как же сделать так, чтобы наши Preview попали в этот список:

  • поменять struct на class
  • добавить поддержку PreviewHolder

т.е. вместо

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

станет

class ContentView_Previews: PreviewProvider, PreviewHolder {
    static var previews: some View {
        ContentView()
    }
}

Опционально можно переопределять name и starred.

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

Скачать проект с базовой реализацией можно здесь: Github

Tagged with: