【SwiftUI】UIViewRepresentableでTextViewを作る

はじめに

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の助けになれば幸いです。