SwiftUI:四种方式自定义TextField

TextFieldStyle

在考虑自定义之前,我们应该考虑SwiftUI提供什么。TextField有自己的风格,给我们提供了一些选项:

  • DefaultTextFieldStyle
  • PlainTextFieldStyle
  • RoundedBorderTextFieldStyle
defaultstyles.png
VStack {
  Section(header: Text("DefaultTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(DefaultTextFieldStyle())

  Section(header: Text("PlainTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(PlainTextFieldStyle())

  Section(header: Text("RoundedBorderTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(RoundedBorderTextFieldStyle())
}

DefaultTextFieldStyleTextField的默认样式,在iOS中,这匹配了PlainTextFieldStyle

PlainTextFieldStyleRoundedBorderTextFieldStyle区别似乎只是一个圆角和边框,然而一个RoundedBorderTextFieldStyleTextField还带有一个白色/黑色背景(取决于环境外观),而TextField PlainTextFieldStyle是透明的:

defaultstylesBackground.png
VStack {
  Section(header: Text("DefaultTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(DefaultTextFieldStyle())

  Section(header: Text("PlainTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(PlainTextFieldStyle())

  Section(header: Text("RoundedBorderTextFieldStyle").font(.headline)) {
    TextField("Placeholder", text: .constant(""))
    TextField("Placeholder", text: $text)
  }
  .textFieldStyle(RoundedBorderTextFieldStyle())
}
.background(Color.yellow)

这是系统的方式,下面让我们说说自定义的方式

方式1: swiftUI方式

没有公共的API来创建新的TextField的样式的,推荐的方式就是对TextField进行一次包装:

public struct FSTextField: View {
  var titleKey: LocalizedStringKey
  @Binding var text: String

  /// Whether the user is focused on this `TextField`.
  @State private var isEditing: Bool = false

  public init(_ titleKey: LocalizedStringKey, text: Binding<String>) {
    self.titleKey = titleKey
    self._text = text
  }

  public var body: some View {
    TextField(titleKey, text: $text, onEditingChanged: { isEditing = $0 })
      // Make sure no other style is mistakenly applied.
      .textFieldStyle(PlainTextFieldStyle())
      // Text alignment.
      .multilineTextAlignment(.leading)
      // Cursor color.
      .accentColor(.pink)
      // Text color.
      .foregroundColor(.blue)
      // Text/placeholder font.
      .font(.title.weight(.semibold))
      // TextField spacing.
      .padding(.vertical, 12)
      .padding(.horizontal, 16)
      // TextField border.
      .background(border)
  }

  var border: some View {
    RoundedRectangle(cornerRadius: 16)
      .strokeBorder(
        LinearGradient(
          gradient: .init(
            colors: [
              Color(red: 163 / 255.0, green: 243 / 255.0, blue: 7 / 255.0),
              Color(red: 226 / 255.0, green: 247 / 255.0, blue: 5 / 255.0)
            ]
          ),
          startPoint: .topLeading,
          endPoint: .bottomTrailing
        ),
        lineWidth: isEditing ? 4 : 2
      )
  }
}
customSwiftUI.gif

这是可以真正的自定义一个TextField。没有办法改变占位符文本的颜色,或者设置不同文本的字体的大小:我们可以通过使用外部文本甚至在跟踪TextField状态时应用掩码来绕过一些限制,但是我们会很快遇到其他的困境,例如键盘操作相关的一些内容。

在ios15之后,你可以通过使用FocusState属性包装器消除SwiftUI上的键盘。

@FocusState private var textFieldFocused: Bool

VStack {
    if showName {
        Text("Your name is \(name)")
    }
    TextField("Name", text: $name)
        .submitLabel(.next)
        .focused($textFieldFocused)

    Button("Submit") {
        showName = true
        textFieldFocused = false
    }
}.padding()

方式2: 桥接UIKit方式

TextField不能满足我们的需求时,我们可以回到UIKit的UITextField.这需要创建一个UIViewRepresentable:

struct UIKitTextField: UIViewRepresentable {
  var titleKey: String
  @Binding var text: String

  public init(_ titleKey: String, text: Binding<String>) {
    self.titleKey = titleKey
    self._text = text
  }

  func makeUIView(context: Context) -> UITextField {
    let textField = UITextField(frame: .zero)
    textField.delegate = context.coordinator
    textField.setContentHuggingPriority(.defaultHigh, for: .vertical)
    textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
    textField.placeholder = NSLocalizedString(titleKey, comment: "")

    return textField
  }

  func updateUIView(_ uiView: UITextField, context: Context) {
    if text != uiView.text {
        uiView.text = text
    }
  }

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  final class Coordinator: NSObject, UITextFieldDelegate {
    var parent: UIKitTextField

    init(_ textField: UIKitTextField) {
      self.parent = textField
    }

    func textFieldDidChangeSelection(_ textField: UITextField) {
      guard textField.markedTextRange == nil, parent.text != textField.text else {
        return
      }
      parent.text = textField.text ?? ""
    }

    func textFieldShouldReturn(_ textField: UITextField) -> Bool {
      textField.resignFirstResponder()
      return true
    }
  }
}

与SwiftUI的TextField使用比较:

struct ContentView: View {
  @State var text = ""

  var body: some View {
    VStack {
      TextField("Type something... (SwiftUI)", text: $text)
      UIKitTextField("Type something... (UIKit)", text: $text)
    }
  }
}
uikitswiftui.gif

一旦我们有了这个基本TextField文本框,我们可以继续获取所有需要的UIKit功能,例如,改变占位符的文本颜色现在需要在UIKitTextFieldmakeUIView(context:)方法中添加以下代码:

textField.attributedPlaceholder = NSAttributedString(
  string: NSLocalizedString(titleKey, comment: ""),
  attributes: [.foregroundColor: UIColor.red]
)
red.png

有了UIKit,我们可以做更多的事情,而不仅仅是简单的定制。例如,我们可以将日期/选择器和键盘类型与我们的TextField文本字段关联起来,这两种类型在SwiftUI中都不支持。更重要的是,我们可以使任何文本字段成为第一响应者。

对于一个高级的TextField UIViewRepresentable示例,我建议查看SwiftUIX's CocoaTextField

方式3: Introspect

尽管SwiftUI APIs 与UIKit非常不同,但通常UIKit仍然在幕后使用。在iOS 14中,TextField的底层仍然是使用的UITextField:记住这一点,我们可以遍历TextField的UIKit层次结构,并寻找相关的UITextField

SwiftUI库Introspect所要做的就是,允许我们接触到与SwiftUI视图对应的UIKit视图,从而让我们解锁UIKit的性能和管理,而无需创建我们自己的UIViewRepresentable:

import Introspect

struct ContentView: View {
  @State var text = ""

  var body: some View {
    TextField("Type something...", text: $text)
      .introspectTextField { textField in
        // this method will be called with our view's UITextField (if found)
        ...
      }
  }
}

例如,SwiftUI没有办法将工具栏与给定的文本字段关联起来,我们可以使用Introspect来修补它:

struct ContentView: View {
  @State var text = ""

  var body: some View {
    TextField("Type something...", text: $text)
      .introspectTextField(customize: addToolbar)
  }

  func addToolbar(to textField: UITextField) {
    let toolBar = UIToolbar(
      frame: CGRect(
        origin: .zero,
        size: CGSize(width: textField.frame.size.width, height: 44)
      )
    )
    let flexButton = UIBarButtonItem(
      barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace,
      target: nil,
      action: nil
    )
    let doneButton = UIBarButtonItem(
      title: "Done",
      style: .done,
      target: self,
      action: #selector(textField.didTapDoneButton(_:))
    )
    toolBar.setItems([flexButton, doneButton], animated: true)
    textField.inputAccessoryView = toolBar
  }
}

extension  UITextField {
  @objc func didTapDoneButton(_ button: UIBarButtonItem) -> Void {
    resignFirstResponder()
  }
}

超过20行添加一个Done按钮!

toolbar.gif

虽然这种方法现在很有效,但不能保证在未来的iOS版本中也能有效,因为我们依赖于SwiftUI的私有实现细节。
使用Introspect是安全的:当SwiftUI的TextField将不再使用UITextField时,我们的自定义方法(addToolbar(to)在上面的例子)将不会被调用。

方式4: TextFieldStyle

在文章的开头提到了SwiftUI不允许我们创建自己的TextFieldStyle
在Xcode 12.5中,这是完整的TextFieldStyle声明:

/// A specification for the appearance and interaction of a text field.
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol TextFieldStyle {

}

然而,它实际上可以通过一个“hidden”_body方法来创建我们自己的样式,因此我们可以这样考虑实际的TextFieldStyle声明如下:

public protocol TextFieldStyle {
  associatedtype _Body: View
  @ViewBuilder func _body(configuration: TextField<Self._Label>) -> Self._Body
  typealias _Label = _TextFieldStyleLabel
}

这让创建我们自己的样式成为可能:

struct FSTextFieldStyle: TextFieldStyle {
  func _body(configuration: TextField<_Label>) -> some View {
     //
  }
}

下面是我们如何用一个新的FSTextFieldStyle来替换之前的FSTextField声明:

struct ContentView: View {
  @State var text = ""

  /// Whether the user is focused on this `TextField`.
  @State private var isEditing: Bool = false

  var body: some View {
    TextField("Type something...", text: $text, onEditingChanged: { isEditing = $0 })
      .textFieldStyle(FSTextFieldStyle(isEditing: isEditing))
  }
}

struct FSTextFieldStyle: TextFieldStyle {
  /// Whether the user is focused on this `TextField`.
  var isEditing: Bool

  func _body(configuration: TextField<_Label>) -> some View {
    configuration
      .textFieldStyle(PlainTextFieldStyle())
      .multilineTextAlignment(.leading)
      .accentColor(.pink)
      .foregroundColor(.blue)
      .font(.title.weight(.semibold))
      .padding(.vertical, 12)
      .padding(.horizontal, 16)
      .background(border)
  }

  var border: some View {
    RoundedRectangle(cornerRadius: 16)
      .strokeBorder(
        LinearGradient(
          gradient: .init(
            colors: [
              Color(red: 163 / 255.0, green: 243 / 255.0, blue: 7 / 255.0),
              Color(red: 226 / 255.0, green: 247 / 255.0, blue: 5 / 255.0)
            ]
          ),
          startPoint: .topLeading,
          endPoint: .bottomTrailing
        ),
        lineWidth: isEditing ? 4 : 2
      )
  }
}
customSwiftUI.gif

不幸的是,这种方法使用了私有API,使用起来不安全:希望我们很快就能得到一个正式的API。

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

推荐阅读更多精彩内容