Написать пост

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

Аватар Типичный программист

Рассказ о том, как видеть все тестовые View при запуске приложения и иметь возможность выбрать, с чем работать.

Рассказывает Александр, старший iOS-разработчик Noveo

Когда начинаешь исследовать какую-то обширную тему в программировании, количество скачанных проектов и созданных черновиков постепенно превышает все мыслимые и немыслимые пределы. А потом и вовсе всё перемешивается и теряется. Вроде помнил, что ты с этим работал, а где, когда? Я пробовал работать в 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, как оно будет отображатьcя в списке; учитывая, что все 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 и обычные. Выглядеть все будет примерно так:

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

Ну, и в 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

Мобильная разработка
iOS
Swift
Для продолжающих
3738