はじめに
SwiftUIでは現状、UITextViewに相当するものが存在しません。
なので、今回はUIViewRepresentableを使ってUITextViewをラップし、TextViewを作ってみたいと思います。
とは言え、もうその程度のことは数多のWebサイトで紹介されつくしているので、ここでは部分、部分でしか紹介されていない「TextViewに欲しい機能」や「ちょっとした不具合の修正」をひとつにまとめて、ある程度実用的なTextViewにしたいと思います。
製作・実行環境は Xcode 11.4.1 / Swift 5.2 です。
完成品デモ
↓こんな感じのものを作ります。
大まかな機能を挙げると、
- Placeholderが付いている
- 独自ボタンが乗ったInputAccessoryView
- ハードウェアキーボードが接続された場合のInputAccessoryViewのSafe Area考慮
- TextViewから変更と終了のイベントを受けられる
の4点です。
作るもの
今回のTextViewのため、以下の4つにパーツを分けて作成していきます。(作成順)
・ViewStyles.swift
色やフォントなどの設定をまとめたファイル。
・InputAccessoryView.swift
UITextViewのinputAccessoryViewプロパティに設定する、独自ボタンの土台になるViewを定義したファイル。
・PlaceholderTextView.swift
UITextViewを継承し、Placeholderの表示機能を加えたカスタムUITextViewのファイル。
・TextView.swift
PlaceholderTextViewをSwiftUIのView階層に乗せるための、UIViewRepresentableを実装したViewのファイル。
では順に見ていきます。
ViewStyles.swift
色やフォントの設定をまとめたファイルです。
import UIKit
/// String extension for convenient of substring
extension String {
/// Index with using position of Int type
func index(at position: Int) -> String.Index {
return index((position.signum() >= 0 ? startIndex : endIndex), offsetBy: position)
}
/// Subscript for using like a "string[start..<end]"
subscript (bounds: CountableRange<Int>) -> String {
let start = index(at: bounds.lowerBound)
let end = index(at: bounds.upperBound)
return String(self[start..<end])
}
}
extension UIColor {
/// Convenience initializer
/// - Parameter rgba: Hexadecimal-String
convenience init(rgba: String) {
let r = Int(rgba[0..<2], radix: 16)!
let g = Int(rgba[2..<4], radix: 16)!
let b = Int(rgba[4..<6], radix: 16)!
let a = CGFloat(rgba.count >= 8 ? Int(rgba[6..<8], radix: 16)! : 255) / 255.0
self.init(r: r, g: g, b: b, a: a)
}
/// Convenience initializer
/// - Parameters:
/// - r: Red color (0...255)
/// - g: Green color (0...255)
/// - b: Blue color (0...255)
/// - a: Alpha (0.0...1.0)
convenience init(r: Int, g: Int, b: Int, a: CGFloat = 1.0) {
self.init(red: CGFloat(r) / 255.0, green: CGFloat(g) / 255.0, blue: CGFloat(b) / 255.0, alpha: a)
}
}
extension UIFont {
static let inputText = UIFont(name: "AvenirNext-Regular", size: 17)!
static let placeholderText = UIFont.inputText
static let toolbarButtonTitle = UIFont(name: "AvenirNext-Medium", size: 16)!
}
extension UIColor {
static let placeholderText = UIColor(rgba: "7d7c7a80")
static let doneButton = UIColor(rgba: "246eb9a0")
static let clearButton = UIColor(rgba: "7d7c7a80")
}
個人的に色やフォントの設定はUIColorやUIFontのextensionを作って、そこにstaticメンバーで定義するのが楽で好きです。
あと、UIColorのinitメソッドはパラメータが指定しづらいものばかりなので(Web等で色を参考にした時、いちいち少数(0.0〜1.0)に直したりするのが面倒)、RGBAの文字列や0〜255の整数で指定できるextensionを書いて使っています。
Stringのextensionは以前書いたこちらの記事の部分流用となっています。
InputAccessoryView.swift
ソフトウェアキーボードの上部に付くInputAccessoryViewの土台となるViewです。
import UIKit
/// InputAccessoryView
/// A container for putting custom buttons on the inputAccessoryView of UITextView or UITextField.
class InputAccessoryView: UIView {
/// The height of internal toolbar
private static let ToolbarHeight: CGFloat = 44.0
/// Toolbar
private(set) var toolbar: UIToolbar!
/// Layout constraints
private var leftConstraint: NSLayoutConstraint? = nil
private var rightConstraint: NSLayoutConstraint? = nil
/// The size that need for appearing subviews.
override var intrinsicContentSize: CGSize {
var size = bounds.size
size.height = InputAccessoryView.ToolbarHeight + safeAreaInsets.bottom
return size
}
/// Required Initializer
required init?(coder: NSCoder) {
super.init(coder: coder)
initialize()
}
/// Initializer
init() {
super.init(frame: .zero)
initialize()
}
/// Initializer
override init(frame: CGRect) {
super.init(frame: frame)
initialize()
}
/// Common Initializer
private func initialize() {
// Create a UIToolbar.
toolbar = UIToolbar()
// Set layout constraints to the toolbar to fit my own bounds.
addSubview(toolbar)
toolbar.translatesAutoresizingMaskIntoConstraints = false
toolbar.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
toolbar.leftAnchor.constraint(equalTo: self.leftAnchor).isActive = true
toolbar.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
toolbar.heightAnchor.constraint(equalToConstant: InputAccessoryView.ToolbarHeight).isActive = true
// Turn off the autoresizing ability.
translatesAutoresizingMaskIntoConstraints = false
}
/// Called when the safe area insets changes.
override func safeAreaInsetsDidChange() {
super.safeAreaInsetsDidChange()
// Call following two functions to make the view's size recalculate.
invalidateIntrinsicContentSize()
setNeedsUpdateConstraints()
}
/// Called when its own is removed from its superview.
override func removeFromSuperview() {
super.removeFromSuperview()
// Clear layout constraints.
leftConstraint?.isActive = false
leftConstraint = nil
rightConstraint?.isActive = false
rightConstraint = nil
}
/// Called when its own is moved to other superview.
override func didMoveToSuperview() {
super.didMoveToSuperview()
if let superview = superview {
// Reset layout constraints to fit new superview's bounds.
leftConstraint?.isActive = false
leftConstraint = self.leftAnchor.constraint(equalTo: superview.leftAnchor)
leftConstraint!.isActive = true
rightConstraint?.isActive = false
rightConstraint = self.rightAnchor.constraint(equalTo: superview.rightAnchor)
rightConstraint!.isActive = true
}
}
}
端末にハードウェアキーボードが接続されるとソフトウェアの方は隠れますが、InputAccessoryViewだけは画面上に残ります。
その時にSafe Areaを考慮した作りにしないとInputAccessoryViewが埋もれてしまうため、それをここで行っています。
具体的には、intrinsicContentSizeというプロパティでSafe Areaも加味した高さ(ソフトウェアキーボード使用時は、safeAreaInsets.bottomは0になります)を返してあげると同時に、そのSafe Areaのサイズが変更された時のイベントをハンドルして都度更新をかけることで、土台の表示に必要な高さを動的に調整してあげています。(横幅については、didMoveToSuperviewの中で親と一致するよう制約を設定)
※ intrinsicContentSizeについてはこちら↓の記事がわかりやすかったです🙇♂️
Qiita – 「iOSのAutoLayoutにおけるIntrinsic Content Sizeについて」
また、ボタンを乗せる実際の土台は、ここではUIToolbarを使用しています。
Auto Layoutの制約で上辺と左右を親に合わせ、高さのみ固定としています。
UIToolbarを使わない場合(UIStackViewやUIView等)はここを変えていただければOKです。
PlaceholderTextView.swift
UITextViewを継承し、Placeholderを加えたViewです。
また、extensionでInputAccessoryViewに乗せるボタンを作って自身にセットする処理も入っています。
import UIKit
/// PlaceholderTextView
/// Wrapped UITextView to be able to show Placeholder.
class PlaceholderTextView: UITextView {
/// Placeholder Label
private(set) var placeholderLabel: UILabel!
/// Placeholder text
var placeholder: String = "" {
willSet {
self.placeholderLabel.text = newValue
self.placeholderLabel.sizeToFit()
}
}
/// Initializer
override init(frame: CGRect, textContainer: NSTextContainer?) {
super.init(frame: frame, textContainer: textContainer)
initialize()
}
/// Required Initializer
required init?(coder: NSCoder) {
super.init(coder: coder)
initialize()
}
/// Common Initializer
private func initialize() {
// Adjust the content area.
textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 8, right: 0)
textContainer.lineFragmentPadding = 0
// Create a UILabel for showing the placeholder.
placeholderLabel = UILabel(frame: CGRect(x: 0, y: 2, width: 0, height: 0))
addSubview(placeholderLabel)
// Add an observer for getting notifications when the input text changes.
NotificationCenter.default.addObserver(self, selector: #selector(textChanged(notification:)), name: UITextView.textDidChangeNotification, object: nil)
}
/// Deinitializer
deinit {
// Remove added observers.
NotificationCenter.default.removeObserver(self)
}
/// Called when the text changes.
@objc private func textChanged(notification: NSNotification) {
updateView()
}
/// Update the view appearance.
func updateView() {
// The placeholder label is showing as long as input text is empty.
// Or, also if the placeholder text itself is empty, make the label hidden.
self.placeholderLabel.isHidden = (placeholder.isEmpty || !text.isEmpty)
}
}
extension PlaceholderTextView {
// Set custom buttons to my own InputAccessoryView.
func setInputAccessoryView() {
/// Clear Button
let clearButton = UIButton(type: .custom)
clearButton.frame = CGRect(x: 0, y: 0, width: 100, height: 36)
clearButton.layer.cornerRadius = 8.0
clearButton.titleLabel?.font = .toolbarButtonTitle
clearButton.setTitle("Clear", for: .normal)
clearButton.setTitleColor(.white, for: .normal)
clearButton.backgroundColor = .clearButton
clearButton.addTarget(self, action: #selector(tapClearButton(sender:)), for: .touchUpInside)
/// Done Button
let doneButton = UIButton(type: .custom)
doneButton.frame = CGRect(x: 0, y: 0, width: 100, height: 36)
doneButton.layer.cornerRadius = 8.0
doneButton.titleLabel?.font = .toolbarButtonTitle
doneButton.setTitle("Done", for: .normal)
doneButton.setTitleColor(.white, for: .normal)
doneButton.backgroundColor = .doneButton
doneButton.addTarget(self, action: #selector(tapDoneButton(sender:)), for: .touchUpInside)
/// InputAccessoryView made with UIToolbar. (Refer to the InputAccessoryView.swift file)
let inputAccessoryView = InputAccessoryView()
inputAccessoryView.toolbar.setItems([
UIBarButtonItem(customView: clearButton),
UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil),
UIBarButtonItem(customView: doneButton),
], animated: false)
inputAccessoryView.toolbar.updateConstraintsIfNeeded()
self.inputAccessoryView = inputAccessoryView
}
/// Called when the clear button is tapped.
@objc private func tapClearButton(sender: UIButton) {
// Clear inputted text and update view appearances.
self.text = ""
self.updateView()
}
/// Called when the done button is tapped.
@objc private func tapDoneButton(sender: UIButton) {
// Call endEditing function to hide the keyboard.
self.endEditing(true)
}
}
Placeholderを付けるやり方はこちら↓を参考にさせていただきました。🙏
Qiita – UITextViewにプレースホルダーを設定できるようにする(Swift4)
これに加え、34, 35行目と38行目で、入力したテキストとプレースホルダーのテキストの位置が揃うように調整してあげています。
extensionの方は、上記にて紹介したInputAccessoryViewを作成し、独自のボタン2つを乗せることをしています。
UIToolbarにカスタムなView乗せると↓にあるようなAuto layoutのWarningが出ますが、
Apple Developer Forums – 「UIToolbar issue in iOS 13」
97行目にあるように、setItemsした後でupdateConstraintsIfNeededを呼んであげたら消えました。(謎)
TextView.swift
最後にTextViewです。
UIViewRepresentableを使って、PlaceholderTextViewをSwiftUIのView階層に載せられるようにしています。
import UIKit
import SwiftUI
import Combine
struct TextView: UIViewRepresentable {
@Binding private var text: String
private(set) var placeholder: String
fileprivate let didEndSubject = PassthroughSubject<Void, Never>()
fileprivate let didChangeSubject = PassthroughSubject<Void, Never>()
/// Initializer
init(_ placeholder: String, text: Binding<String>) {
self.placeholder = placeholder
self._text = text
}
/// Create a coordinator to coordinate the UIView.
func makeCoordinator() -> TextView.Coordinator {
return Coordinator(self)
}
/// Create a UIView that actually displayed.
func makeUIView(context: UIViewRepresentableContext<TextView>) -> PlaceholderTextView {
// Create a PlaceholderTextView
let textView = PlaceholderTextView()
textView.delegate = context.coordinator
textView.font = .inputText
textView.placeholderLabel.font = .placeholderText
textView.placeholderLabel.textColor = .placeholderText
textView.setInputAccessoryView()
return textView
}
/// Update the UIView states.
func updateUIView(_ uiView: PlaceholderTextView, context: UIViewRepresentableContext<TextView>) {
uiView.text = text
uiView.placeholder = placeholder
uiView.updateView()
// Update the textview value which the coordinator has.
context.coordinator.textView = self
}
/// Call actions when each events occur.
func onEvent(onChanged: (() -> Void)? = nil, onEnded: (() -> Void)? = nil) -> some View {
return onReceive(didChangeSubject) {
onChanged?()
}
.onReceive(didEndSubject) {
onEnded?()
}
}
}
extension TextView {
final class Coordinator: NSObject, UITextViewDelegate {
fileprivate var textView: TextView
/// Initializer
init(_ textView: TextView) {
self.textView = textView
}
/// Called when text has changed.
func textViewDidChange(_ textView: UITextView) {
// Feed back to the binding text.
self.textView.text = textView.text
// Send to the subscriber.
self.textView.didChangeSubject.send()
}
/// Called when editing has ended.
func textViewDidEndEditing(_ textView: UITextView) {
// Feed back to the binding text.
self.textView.text = textView.text
// Send to the subscriber.
self.textView.didEndSubject.send()
}
}
}
UIViewRepresentableの基本的な使い方については他のサイト様をご参照いただくとして、ここで挙げるべきポイントは2点。
1点目はCombineのPassthroughSubjectを使って、UITextViewDelegateのイベントをSwiftUIのView階層側で検知できるようにしているところ。
2点目は、updateUIViewメソッドの中でCoordinatorが所持するTextViewの値をその都度更新しているところです。
・まず1点目について。
TextViewのメンバーとして2つのPassthroughSubjectを用意し(11, 12行目)、Coordinatorの中でUITextViewDelegateの各イベントが発生した時に、sendを呼んでSubscriberたちに通知を行っています。
そのSubscribeは誰がどこで行うかと言うと、このTextViewを使う外側のViewの中で、onEvent(51行目)を呼び出して行います。
SwiftUIのViewにはもともとからonReceiveという「指定したPublisher(もしくはSubject)から通知があった場合に、指定したアクションを実行する」というメソッドが付いていますので、これを利用してSubjectとSubscriber(引数で指定したクロージャ)を繋げてあげています。
onEventを使う側のコードは下記に挙げますが、これでUITextViewDelegateのイベントを外に伝えてあげることが可能になりました。
・次に2点目。
これがなぜ必要なのかと言うと、それはSwiftUIの仕組みにあります。
SwiftUIでは@State(からの@Binding)や@ObservedObject(ObservableObjectクラスとその中の@Published)などで定義されたデータが変更されると、それに影響されるViewのbodyプロパティが呼び出され、そのbody内に書かれたViewの再作成(initの呼び出し)が行われます。
※これについては以下の記事が参考になります。👏
Qiita – 「SwiftUIのSubViewは画面更新ごとに生成と破壊を繰り返す」
この時、TextViewが持つ2つのSubjectも再作成されるわけですが、しかしCoordinatorは最初の1回(UIKitのView階層にPlaceholderTextViewを乗せる必要がある時)しか呼ばれないため、Coordinatorが持つTextViewはずっと古いままとなってしまいます。
そうするとonEventの中で使用しているSubjectとUITextViewDelegateのイベント発生時に使用しているSubjectは別物となってしまうため、いくらsendを行っても通知は届かなくなってしまいます。
これを解決するために、ここではupdateUIViewメソッドの中で都度、Coordinatorの持つTextViewもリフレッシュしています。
以上、これで必要なパーツが揃いました。
次は実際にTextViewを使う側を載せたいと思います↓
TextViewを使う側
ContentView
上で作成したTextViewを使ってみます。
import SwiftUI
struct ContentView: View {
@State private var text: String = ""
@State private var isEditing: Bool = false
@State private var version: Int = 0
var body: some View {
VStack(alignment: .center, spacing: 16) {
Text("Hello, TextView!")
HStack(alignment: .center, spacing: 8) {
InputLamp(isEditing: self.$isEditing)
.frame(width: 16, height: 16)
Text("Count: \(self.text.count)")
}
Text("Version: \(self.version)")
TextView("Input text", text: self.$text)
.onEvent(onChanged: {
self.isEditing = true
withAnimation(.linear(duration: 0.5)) {
self.isEditing = false
}
}, onEnded: {
self.version += 1
})
}
.padding(16)
}
}
struct InputLamp: View {
@Binding var isEditing: Bool
var body: some View {
Circle()
.overlay(Circle().stroke(Color(UIColor(rgba: "e0e0e0")), lineWidth: 1))
.foregroundColor(self.isEditing ? Color.red : Color.white)
}
}
TextViewの作成のあとにonEventと続けて、テキスト変更時に赤丸のランプ(InputLamp)を付ける、終了時にVersionを+1する、といったことをやっています。
ここは特に難しいことはないですね。
ひとまず、これにて完成!
完成品
ここで紹介したファイルはGithubにアップしています。
https://github.com/hk2ndwalker/iOS/tree/master/SwiftUI/TextView
おわりに
SwiftUIはいろいろクセがあって慣れるまでしんどいし、あらかじめ用意されているコントローラはかゆいところに手が届かなかったりと、楽にできるようでなかなか楽ではありません。
となれば、今回のようにいっそ何でも自作してしまうのがレガシーを活かせてかつ一番シンプルな気がするというのが、SwiftUIを触っていて得た感想だったりします。
何はともあれ、今回の記事が一人でも多くのSwiftUIerの助けになれば幸いです。