与苹果之前的UI框架相比,SwiftUI的一个主要不同之处在于它的视图创建和配置方式。事实上,在使用SwiftUI的时候,我们实际上从来没有创建过任何视图——相反,我们只是简单地描述我们想要的UI的样子,然后由系统来处理实际的渲染。

本周,让我们来看看构建这些视图描述的几种不同技术,以及每种方法在代码结构和灵活性方面给我们带来的优缺点。

Initializers, modifiers and inheritance

总的来说,有三种不同的方式来配置SwiftUI视图——通过向其初始化器传递参数,使用修饰符,以及通过其周围的环境。例如,这里我们正在配置一个Text view,它作为TitleView的主体——使用它的初始化器,并通过应用修饰符来改变它的字体和文本颜色:

struct TitleView: View {
    var title: String

    var body: some View {
        Text(title)
            .font(.headline)
            .italic()
            .foregroundColor(.blue)
    }
}

note:在使用命令式框架(如UIKit或AppKit)时,SwiftUI的声明式编程风格与UI的构造方式的很大一部分是通过上面的方式将修饰符链接在一起,而不是改变单个值。

上面是一个直接配置的例子,因为我们通过直接调用方法来显式地设置和修改我们的文本视图。然而,SwiftUI也支持间接配置,因为许多不同的修饰符和属性会自动通过每个给定的视图层次结构向下传播。

当我们希望多个兄弟视图采用同一种配置或样式时,这种间接的、继承的配置非常有用 一 就像下面的例子,我们配置了一个文本和一个列表来显示它们所有的文本,使用的是等宽字体,只需要将字体赋值给它们的父VStack:

struct ListView: View {
    var title: String
    var items: [Item]
    @Binding var selectedItem: Item?

    var body: some View {
        VStack {
            Text(title).bold()
            List(items, selection: $selectedItem) { item in
                Text(item.title)
            }
        }.font(.system(.body, design: .monospaced))
    }
}

事实上,整个SwiftUI视图的层次结构都可以通过它们的父视图来配置,这一点非常强大,因为它允许我们应用共享的样式和配置,而不必单独修改每个视图。

这不仅常常会减少代码,而且还为我们的共享配置建立了一个单一的真相来源 — 比如字体、颜色等等——不需要我们引入任何抽象来实现这些。

让我们来看另一个例子,在这个例子中,我们只需将整个导航堆栈的accentColor分配给我们的根导航视图即可 — 颜色会被应用到所有的子视图,包括那些由系统管理的视图,比如我们定义的导航栏项

struct ContactListView: View {
    @ObservedObject var contacts: ContactList

    var body: some View {
        NavigationView {
            List(contacts) { contact in
                ...
            }
            .navigationBarItems(
                trailing: Button(
                    action: { ... },
                    label: {
                        // This image will be colored purple
                        Image(systemName: "person.badge.plus")
                    }
                )
            )
        }.accentColor(.purple)
    }
}

然而,有时我们可能希望将一组样式应用到一组视图,而不必改变它们与父视图的关系。例如,假设我们正在构建一个用于在应用程序中显示地址的视图,它由一系列堆叠的文本视图组成:

struct AddressView: View {
    var address: Address

    var body: some View {
        VStack(alignment: .leading) {
            Text(address.recipient)
                .font(.headline)
                .padding(3)
                .background(Color.secondary)
            Text(address.street)
                .font(.headline)
                .padding(3)
                .background(Color.secondary)
            HStack {
                Text(address.postCode)
                Text(address.city)
            }
            Text(address.country)
        }
    }
}

上面我们为前两个标签分配了完全相同的样式,所以让我们看看是否可以统一代码以避免重复它。在这种情况下,我们不能将修饰符应用到标签的父视图,因为我们只想将给定的样式应用到它的子视图的子集。

值得庆幸的是,SwiftUI还提供了一个Group类型,它允许我们将一组视图作为一个组来对待——而不会影响它们在整体视图层次结构中的布局、绘图或位置。使用该类型,我们可以将两个标签组合在一起,然后同时对它们应用我们的一组修饰符:

struct AddressView: View {
    var address: Address

    var body: some View {
        VStack(alignment: .leading) {
            Group {
                Text(address.recipient)
                Text(address.street)
            }
            .font(.headline)
            .padding(3)
            .background(Color.secondary)
            ...
        }
    }
}

Group的力量在于它直接将修饰符应用于它的子对象,而不是它自己。相比之下,如果我们使用另一个VStack来分组我们的标签,这将导致填充和背景颜色被应用到那个堆栈,而不是单独应用到我们的标签。

Views versus extensions

随着基于swift的视图越来越复杂,我们可能需要开始使用多种方式来分组和共享我们的各种配置和样式,以保持代码易于使用。到目前为止,我们主要是通过修改器处理样式,但是UI配置的一个主要部分也归结到我们如何构造视图本身。

假设我们正在处理一个表单让用户在一个应用程序中注册一个账户。为了让我们的表单看起来更漂亮,我们在每个文本字段前面加上了苹果的SF Symbols库中的图标——实现如下所示:

struct SignUpForm: View {
    ...
    @State private var username = ""
    @State private var email = ""

    var body: some View {
        Form {
            Text("Sign up").font(.headline)
            HStack {
                Image(systemName: "person.circle.fill")
                TextField("Username", text: $username)
            }
            HStack {
                Image(systemName: "envelope.circle.fill")
                TextField("Email", text: $email)
            }
            Button(
                action: { ... },
                label: { Text("Continue") }
            )
        }
    }
}

上面我们使用了相同的HStack + Image + TextField组合两次,虽然这并不一定是一个问题,因为我们配置我们的两个文本字段非常不同。— 假设我们还想把这个组合变成一个独立的组件,这样我们就可以在整个应用程序的其他地方重用它。

关于如何做到这一点,最初的想法可能是创建一个新的视图类型,它接受一个iconName和title来显示,还有一个@Binding对text属性的引用,当组件的文本字段被编辑时,我们希望更新这个属性——像这样:

struct IconPrefixedTextField: View {
    var iconName: String
    var title: String
    @Binding var text: String

    var body: some View {
        HStack {
            Image(systemName: iconName)
            TextField(title, text: $text)
        }
    }
}

有了上面的步骤,我们现在可以回到SignUpForm,用新的IconPrefixedTextField组件的实例来替换之前重复的HStack配置:

struct SignUpForm: View {
    ...

    var body: some View {
        Form {
            ...
            IconPrefixedTextField(
                iconName: "person.circle.fill",
                title: "Username",
                text: $username
            )
            IconPrefixedTextField(
                iconName: "envelope.circle.fill",
                title: "Email",
                text: $email
            )
            ...
        }
    }
}

然而,尽管上面的改变将使我们能够在SignUpForm之外重用新的IconPrefixedTextField类型,但它是否最终改善了我们的原始代码还是值得怀疑的。 毕竟,我们并没有使注册表单的实现变得更简单——事实上,上面的调用站点看起来比它之前做的要复杂得多。

相反,让我们从SwiftUI自己的API设计中汲取一些灵感,看看如果我们将文本视图配置代码实现为视图扩展会是什么样子。这样,任何视图都可以用一个图标作为前缀,只需调用以下方法:

extension View {
    func prefixedWithIcon(named name: String) -> some View {
        HStack {
            Image(systemName: name)
            self
        }
    }
}

有了上面的内容,我们现在可以将任何SF Symbols图标直接添加到SwiftUI的原生文本框视图中——或者其他视图中——就像这样:

struct SignUpForm: View {
    ...

    var body: some View {
        Form {
            ...
            TextField("Username", text: $username)
                .prefixedWithIcon(named: "person.circle.fill")
            TextField("Email", text: $email)
                .prefixedWithIcon(named: "envelope.circle.fill")
            ...
        }
    }
}

在构建一个新的视图实现和一个扩展之间做出选择有时是相当困难的,而且在这里并没有明确的正确或错误的处理方式。然而,当我们发现自己创建的只是将属性传递给其他视图的新视图类型时,可能有必要问问自己,这段代码作为扩展是否会更好地工作。

Modifier types

除了编写视图扩展之外,SwiftUI还允许我们定义自定义的视图修饰符,作为符合ViewModifier协议的类型。这样做使我们能够编写具有自己的属性、状态和生命周期的修饰符——这些修饰符可以用来扩展SwiftUI并提供各种新功能。

例如,假设我们想要在之前的注册表单中添加内联验证, 一旦用户输入了有效的字符串,就将每个文本字段的边框变成绿色。虽然这是我们可以直接在SignUpForm视图中实现的,但让我们把这个特性构建成一个完全可重用的ViewModifier:

struct Validation<Value>: ViewModifier {
    var value: Value
    var validator: (Value) -> Bool

    func body(content: Content) -> some View {
        // Here we use Group to perform type erasure, to give our
        // method a single return type, as applying the 'border'
        // modifier causes a different type to be returned:
        Group {
            if validator(value) {
                content.border(Color.green)
            } else {
                content
            }
        }
    }
}

看看上面的实现,我们可以看到ViewModifier看起来非常像一个视图,因为它有一个返回某个视图的主体。 区别在于修饰符对现有视图(作为内容传入)进行操作,而不是完全独立。 这样做的好处是,我们现在可以将新的验证功能添加到任何文本字段(或者任何视图,真的),就像使用视图扩展时一样,而不需要构建任何形式的包装器类型:

TextField("Username", text: $username)
    .modifier(Validation(value: username) { name in
        name.count > 4
    })
    .prefixedWithIcon(named: "person.circle.fill")

就像在扩展和全新的视图实现之间进行选择一样,在很多情况下,选择何时将给定的视图配置作为ViewModifier来实现很可能是一个偏好和风格的问题。

然而,ViewModifier和视图类型都有一个优点,那就是它们可以包含自己的一组状态和属性,而扩展则更加轻量级。在接下来的文章中,我们将更深入地研究基于swift的状态和数据管理。

Conclusion

就像它的前辈一样,SwiftUI为我们提供了许多构建UI代码和配置各种视图的方法。虽然我们的许多自定义组件可能会作为独立的视图类型来实现,但是构建我们自己的扩展和修改器可以使我们以一种更轻量级的方式跨代码库共享样式和配置 — 可以让我们把这些构型应用到不止一种类型的视图。

原文链接