SwiftUI - 常用控件

这一节里,我们一起来通过完成一个表单,了解一下SwiftUI中的一些常用控件。其中,涉及的知识点:

  1. TextField
  2. UI控件的一些重构小技巧
  3. 通过依赖注入来实现keyboard事件监听
  4. Button
  5. Toggle
  6. 如何设置一个背景图片

功能很简单,效果图如下所示,这里的UI布局细节不是重点,有需要时,可通过Modifier在进行调整。源码:Github

效果图

TextField

使用方式很简单,通过最基础的构造函数便可把它放在视图中

@State var name: String = ""
...
TextField("Name", text: $name)
...

其中第一个参数就是UIKit中的的hint,第二个参数就是和当前视图绑定的一个state变量,当用户输入参数时,该state变量也会跟着改变。系统还提供了其它的一些构造函数,我们进入TextField的定义来看一看:

extension TextField where Label == Text {
    public init(_ titleKey: LocalizedStringKey, text: Binding<String>, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {})

    public init<S>(_ title: S, text: Binding<String>, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {}) where S : StringProtocol

    public init<T>(_ titleKey: LocalizedStringKey, value: Binding<T>, formatter: Formatter, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {})

    public init<S, T>(_ title: S, value: Binding<T>, formatter: Formatter, onEditingChanged: @escaping (Bool) -> Void = { _ in }, onCommit: @escaping () -> Void = {}) where S : StringProtocol
}

其实就是一些函数重载,第一个参数有两种:其一是直接hardcode,另一种是用来支持多语言的。其余的参数我们分别来看一看:

  1. onEditingChanged:需要的是一个回调函数,该回调函数的入参是一个Bool值。当该TextField获得或失去焦点时,会触发函数的调用,而这个Bool值在获得焦点时为true,失去焦点时为false。
  2. onCommit:是在用户点击键盘的Done时被触发。
  3. formatter:需要传入的是一个Formatter对象,用来格式化显示的内容,比如货币,数字这种。
    可以看到这几个入参都是用来替换UIKit中UITextFieldDelegate

那么,除了这些通过构造函数可以完成的,其余的一些属性都是通过Modifier来实现的,比如键盘的类型通过.keyboardType(UIKeyboardType.emailAddress)设置。style则通过.textFieldStyle(RoundedBorderTextFieldStyle())设置,SwiftUI内置了4中TextFiled的style,分别为no styleDefaultTextFieldStylePlainTextFieldStyleRoundedBorderTextFieldStyle,试一下发现,前三种是没有差别的。通常都不设置样式,而通过一些通用的Modifier来自定义样式,比如示例图中的样式,是通过如下代码进行设置的:

TextField("Name", text: $userManager.profile.name)
  .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
  .background(Color.white)
  .cornerRadius(self.cornerRadius)
  .overlay(
    RoundedRectangle(cornerRadius: self.cornerRadius)
      .stroke(lineWidth: 2)
      .foregroundColor(.blue)
    )
  .shadow(color: Color.gray.opacity(0.4), radius: 3, x: 1, y: 2)

UI控件的一些重构小技巧

看到这个,我们会想到,如果需要有另一个输入框,比如密码输入框,如何来复用这些样式呢?第一个想到的一定是抽取一个UI组件,比如叫MyInputField,再把需要的参数传递进去。

struct MyInputField: View {
  TextField("Name", text: $userManager.profile.name)
    .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
    .background(Color.white)
    .cornerRadius(self.cornerRadius)
    .overlay(
      RoundedRectangle(cornerRadius: self.cornerRadius)
        .stroke(lineWidth: 2)
        .foregroundColor(.blue)
      )
    .shadow(color: Color.gray.opacity(0.4), radius: 3, x: 1, y: 2)
  }
}

但发现,这样会有一些问题,因为SwiftUI中的密码输入框,和UIKit的中的有所不同,它是单独的一个组件叫做SecureField,而我们想要的只是样式。
这里就引入了我们的第二中抽取方式,创建自定义的一个Modifier。

struct BorderedViewModifier: ViewModifier {
    private let cornerRadius: CGFloat = 8
    func body(content: Content) -> some View {
        content
            .padding(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
            .background(Color.white)
            .cornerRadius(self.cornerRadius)
            .overlay(
                RoundedRectangle(cornerRadius: self.cornerRadius)
                    .stroke(lineWidth: 2)
                    .foregroundColor(.blue)
            )
            .shadow(color: Color.gray.opacity(0.4), radius: 3, x: 1, y: 2)
    }
}

extension View {
    func bordered() -> some View {
        ModifiedContent(content: self, modifier: BorderedViewModifier())
    }
}

这里分了两步,第一步构造一个自定义的ViewModifier,实现它的一个方法,在此方法中,把想要复用的样式写进入,想要用这个样式只需要:
ModifiedContent(content: someView, modifier: BorderedViewModifier())
再进一步,可以写一个View的extension,定义如上的一个方法bordered()。这样要使用这个样式就更方便了

 TextField("Name", text: $userManager.profile.name).bordered()

通过依赖注入来实现keyboard事件监听

谈到了输入框,就必然会想到keyboard,iOS不像Android的那样,系统会自动将输入框上移,避免被挡住。iOS就需要自己动手了。这里推荐的一个方式是通过@ObservedObject进行依赖注入一个keyboard的处理类,实现方式和UIKit也是一样的,监听系统的一个Notification:

import UIKit

final class KeyboardFollower: ObservableObject {
    
    @Published var keyboardHeight: CGFloat = 0
    
    func subscribe() {
        NotificationCenter.default.addObserver(self, selector: #selector(keyboardVisibilityChanged), name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
    }
    
    func unsubscribe() {
        NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil)
    }
    
    @objc func keyboardVisibilityChanged(_ notification: Notification) {
        guard let userInfo = notification.userInfo else { return }
        guard let keyboardBeginFrame = userInfo[UIResponder.keyboardFrameBeginUserInfoKey] as? CGRect else { return }
        guard let keyboardEndFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
        let visible = keyboardBeginFrame.minY > keyboardEndFrame.minY
        keyboardHeight = visible ? keyboardEndFrame.height : 0
    }
}

使用起来需要在该View初始化时出入一个KeyboardFollower的实例

struct RegisterView: View {
    @ObservedObject var keyboardHandler: KeyboardFollower
    ...
}

然后监听这个keyboardHandler中的keyboardHeight变更,注意这里有有两个方法.onAppear.onDisappear,它们和原先的ViewController的生命周期方法是相同的,在这个两个方法中进行监听的开启和关闭,在通过padding来改变TextField的位置。

struct RegisterView: View {
    @ObservedObject var keyboardHandler: KeyboardFollower
    @EnvironmentObject var userManager: UserManager

    var body: some View {
        ZStack {
            ...
            VStack {
                WelcomeMessageView()
                TextField("Name", text: $userManager.profile.name)
                    .bordered()
                    .padding([.leading, .trailing])
                ...
            }
            .padding(.bottom, keyboardHandler.keyboardHeight)
            .onAppear { self.keyboardHandler.subscribe() }
            .onDisappear { self.keyboardHandler.unsubscribe() }
        }
    }
}

Button

button相对来说就简单很多,但有一点是它强大之处。在UIKit时,要自定义一个Button是比较麻烦的,尤其是在有图片,文字的布局时。SwiftUI对此做了优化,我们先来看一下它的构造函数:

@available(iOS 13.0, OSX 10.15, tvOS 13.0, watchOS 6.0, *)
public struct Button<Label> : View where Label : View {

    /// Creates an instance for triggering `action`.
    ///
    /// - Parameters:
    ///     - action: The action to perform when `self` is triggered.
    ///     - label: A view that describes the effect of calling `action`.
    public init(action: @escaping () -> Void, @ViewBuilder label: () -> Label)

    /// Declares the content and behavior of this view.
    public var body: some View { get }

    /// The type of view representing the body of this view.
    ///
    /// When you create a custom view, Swift infers this type from your
    /// implementation of the required `body` property.
    public typealias Body = some View
}

它的构造函数的一个参数是一个block,没什么好说的,就是点击事件,第二个参数也是一个block,返回值是Label,而这个Label是View的子类,也就是说,可以根据需要,写任意一个View给它。这一点,真的是可圈可点。

struct SubmitButton: View {
    let action: () -> Void;
    var body: some View {
        Button(action: self.action) {
            HStack {
                Image(systemName: "checkmark")
                    .resizable()
                    .frame(width: 16, height: 16, alignment: .center)
                Text("OK")
                    .font(.body)
                    .bold()
          }
        }
        .bordered()
    }
}

Toggle

看过Button之后,Toggle就简单很多了,和button一样,它的第二个参数也是可以传入任意View的

Toggle(isOn: $userManager.settings.rememberUser) {
  Text("Remember me")
  .font(.subheadline)
  .multilineTextAlignment(.center)
  .foregroundColor(.gray)
}.padding(.trailing)

如何设置一个背景图片

这个应该算是一个题外话了,在设置背景图片时,真的是被坑到了。设置背景一个有两种方式,其一是使用ZStack,让图片位于最顶部;其二,是给某个Stack设置background的Modifier,比如HStack { ... }.background(some image or color)。它们有什么区别呢?
使用background modifier时,背景图片的大小是由该Stack的大小决定的,而使用ZStack的方式,图片的大小就比较自由,但当图片的大小超过屏幕时,比如宽度超过屏幕宽度,在这个ZStack的其他UI组件的默认宽度就是那个图片的宽度。
当需求是如上方图片那样时,就只能使用ZStack了,图片的宽度超过了屏幕宽度,那么就需要把多出去的部分砍掉,折腾了很久后,找到了一个Modifier:.frame(minWidth: 0, maxWidth: .infinity),这样就完成了需求。

WelcomeBackgroundImage

struct WelcomeBackgroundImage: View {
    var body: some View {
        Image("swift_world")
            .resizable()
            .scaledToFill()
            .frame(minWidth: 0, maxWidth: .infinity)
            .edgesIgnoringSafeArea(.all)
            .saturation(0.5)
            .blur(radius: 5)
            .opacity(0.08)
    }
}

RegisterView

import SwiftUI

struct RegisterView: View {
    @ObservedObject var keyboardHandler: KeyboardFollower
    var body: some View {
        ZStack {
            WelcomeBackgroundImage()
            VStack {
                ...
            }
            .padding(.bottom, keyboardHandler.keyboardHeight)
            .onAppear { self.keyboardHandler.subscribe() }
            .onDisappear { self.keyboardHandler.unsubscribe() }
        }
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,258评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,335评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,225评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,126评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,140评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,098评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,018评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,857评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,298评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,518评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,678评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,400评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,993评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,638评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,801评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,661评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,558评论 2 352

推荐阅读更多精彩内容

  • 开始之前:请确保你的系统版本为macOS 10.15及以上版本且已经安装了Xcode 11。这种组合使您可以在Xc...
    Augs阅读 2,990评论 0 7
  • 本文的主角[SwiftUI-CSS](https://github.com/hite/SwiftUI-CSS) 是...
    hite和落雁阅读 2,789评论 0 2
  • 在开战之前,没有任何一个人感觉过我们LPL能赢? 能夺冠。 甚至在比赛中,也因为选手的种种表现,一场比赛的得失,我...
    黄铜刀阅读 207评论 1 3
  • 回答第二个问题: 1、每天坚持(虽然每次老师讲课的时间都是上午8:00,那时正是我在上班路上的时间),我也要尽快在...
    金海锦阅读 124评论 0 0
  • 寻着淡淡的花香, 发现不知不觉 又已经到了栀子花挂满枝头的季节。。。。 小院的花争相斗艳 无奈确深锁闺苑 是错过了...
    alice_99e9阅读 246评论 0 3