UITextViewのプレースホルダを追加したい


概要

UITextFieldはプレースホルダをつけられるけど、UITextViewはつけられないので

完成品

//
//  PlaceHolderedTextView.swift
//  PlaceHolderedTextView
//
//  Created by はるふ on 2016/11/29.
//  Copyright © 2016年 はるふ. All rights reserved.
//

import UIKit

@IBDesignable class PlaceHolderedTextView: UITextView {
    @IBInspectable var placeHolder: String = ""
    @IBInspectable var placeHolderColor: UIColor = .lightGray

    private var placeHolderLayer: CATextLayer?

    private func createPlaceHolderLayerIfNeed() {
        if placeHolderLayer == nil {
            let layer = CATextLayer()
            layer.fontSize = self.font?.pointSize ?? UIFont.systemFontSize
            layer.frame = CGRect(x: self.textContainerInset.left + self.textContainer.lineFragmentPadding, y: self.textContainerInset.top, width: self.frame.width, height: layer.fontSize+10)
            layer.string = self.placeHolder
            layer.foregroundColor = placeHolderColor.cgColor
            layer.contentsScale = UIScreen.main.scale
            layer.alignmentMode = kCAAlignmentLeft
            self.layer.addSublayer(layer)
            placeHolderLayer = layer
        }
    }

    private func removePlaceHolderLayerIfNeed() {
        placeHolderLayer?.removeFromSuperlayer()
        placeHolderLayer = nil
    }

    private func updateLayer() {
        // Observerから呼ばれるとmainじゃないかも?
        DispatchQueue.main.async {
            if self.text == nil || self.text.isEmpty {
                self.createPlaceHolderLayerIfNeed()
            } else {
                self.removePlaceHolderLayerIfNeed()
            }
        }
    }

    func onChangedText(_ notification: NSNotification?) {
        updateLayer()
    }

    // MARK: Observer関連

    private func addObserver() {
        updateLayer()
        NotificationCenter.default.addObserver(self, selector: #selector(self.onChangedText(_:)), name: NSNotification.Name.UITextViewTextDidChange, object: self)
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        addObserver()
    }

    override init(frame: CGRect, textContainer: NSTextContainer?) {
        super.init(frame: frame, textContainer: textContainer)
        addObserver()
    }

    deinit {
        NotificationCenter.default.removeObserver(self)
    }
}

設定方法

  1. 新規ファイルを作成し、上のコードをコピペ
  2. StoryboardにUITextViewを配置する
  3. UITextViewを選択し、クラスを"PlaceHolderedTextView"に設定

以上でStoryboardから編集できるようになります

目指した理想形

探すとすぐに記事が出てきて、だいたい同じようなコードがあるが、
以下の点を鑑み、自分で作ろうと思った。

コードはViewに完結させたい

ViewControllerからいじるほどでもないし、使い回しも考えると、Viewのコードだけで実現できる方が良い

Observerを使いたくなかった(実現できなかった)

実現できなかったが・・・

override var text: String! {
    didSet {
        // ここでupdate
    } 
}

できるかと思ったら、できなかった。微妙なタイミングでしか呼ばれない。

delegateを使うと、ViewController側で使えなくなるので、使わなかった

うまく取れる方法があれば、教えていただきたい。

UILabelまでいらない

わざわざUILabelほどリッチなものはいらないと思った。
極論、一つのViewに完結したコードなので、CoreTextでも良い。

今回はCATextLayerを使いました。

drawでupdateされるコードが多い

これが結構よくわからなかったが、何故かdrawで更新するコードが多い。

func draw(_ rect: CGRect) {
}

でUILabelとかCALayerとかに変更を加えるのは、目的に合ってない。ここは、CoreTextなどを使った場合に描画する所(だと思う)

@IBDesignable, @IBInspectable

Storyboardからプレースホルダの色・文字を選択できる方が良い

表示だけなら、ビルドした後は反映されるっぽい
(triggerとか設定しなくて良いっぽい)

プレースホルダの位置

self.textContainer.lineFragmentPadding (textViewの両端にある5pxぐらいのpadding)
self.textContainerInset(textViewの上下にある8pxぐらいのpadding)

を考慮する必要がある。

でも、これでも何故か下に1, 2pxぐらいずれている気がします・・・CATextLayerにpaddingがあるのかな?と思いますが、詳しい方いれば教えていただきたいです。

おまけ:編集開始したら消す

色の設定によると思いますが、編集開始時にプレースホルダを消したい場合は updateLayer() を以下のように書き換えます

private func updateLayer() {
    // Observerから呼ばれるとmainじゃないかも?
    DispatchQueue.main.async {
        if (self.text == nil || self.text.isEmpty) && !self.isFirstResponder {
            self.createPlaceHolderLayerIfNeed()
        } else {
            self.removePlaceHolderLayerIfNeed()
        }
    }
}