Рассказывает Александр, старший 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 }
}
- Как видно, я стер тип у
Previews
, создав оберткуanyPreviews
, которая будет возвращатьAnyView
. Я не очень люблю такие штуки, потенциальная потеря производительности, но т.к. это не production-код, то на это можно закрыть глаза. name
— свойство, возвращающее имя нашей View, как оно будет отображатьcя в списке; учитывая, что все Preview имеют автоматом генерируемые именаViewName_Previews
, можно_Previews
отрезать.- Я добавил свойство
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