チャット画面の実装を楽にする 綺麗に伸縮するテキストビューの作り方

こんにちは!

Couples iOSの開発を担当している木村です。


今回はiOSアプリのチャット画面に使われるテキスト入力部分の実装についてのTipsになります。

伸縮するテキストビュー

チャット画面でよく見るこの伸縮するテキストビューですが、
HPGrowingTextViewという有名なライブラリの名称からGrowingTextViewと名付けられています。
本記事でもGrowingTextViewと呼びながら説明していきます。

growingtextview

これをどう実装するか

GrowingTextViewの実装方法は対応するOSのバージョンによってアプローチは異なってきますが、
実装方法によっては正しい挙動の実現が大変になることがあります。


以前、私がGrowingTextViewを実装しようとした時は、
UITextViewのサブクラスで実現しようと考えました。
ですが、この方法ではiOSのバージョンによってUITextViewに不具合があり、
挙動が不安定になってしまいました。


(UITextViewの不具合: 主にUITextViewのスクロール機能がおかしくなります。 (特にiOS8))


先ほど紹介したHPGrowingTextViewはしばらくメンテナンスがされていないため、
Autolayout環境だとうまく動かないケースが多く存在しています。

iOSのバージョンによって左右されない安定した実装方法

そこで、iOSのバグに悩まされずに実装がシンプルになる新しい構造を考えてみました。
新しい構造ということで NextGrowingTextViewと呼びながら紹介したいと思います。


ライブラリとしても公開しておりますので、
全体のソースコードはNextGrowingTextViewをご覧ください。

GrowingTextViewの動き方

実装に入る前に、GrowingTextViewの動き方ですが、
LINEやFacebook Messengerなどのチャット画面では以下のような動き方をしています。

  • 最初は1行分のスペースが空いている
  • 文字を入力していくと縦幅が広がっていく (行数に応じて伸縮する)
  • 伸縮の幅には限界値がある (5行分までしか広がらない、など)
  • 伸縮の限界に達したらスクロール可能になる
  • カーソルの位置に合わせてスクロールする

NextGrowingTextViewでもこの動作を実装していきます。

NextGrowingTextViewの構造

NextGrowingTextViewの構造は以下のようになります。

  • NextGrowingTextView (UIScrollViewのサブクラス)
  • UITextView (スクロールさせないようにする)

NextGrowingTextViewがUIScrollViewというのがポイントで、
UITextViewのスクロール機能は利用せず、NextGrowingTextViewにスクロールしてもらうようにします。

Screen Shot 2016-01-20 at 9.26.00 PM

NextGrowingTextViewの実装

NextGrowingTextViewはUITextViewのテキスト変更を監視し、
自分自身のFrameとContentSizeをUITextViewのContentSizeに合わせるように実装し、
NextGrowingTextViewのFrameは伸縮の最小値・最大値で止まるようにします。


まず、NextGrowingTextViewのベースから作っていきます。
UITextViewの初期化と伸縮の最大・最小の値の定義を行います。

public class NextGrowingTextView: UIScrollView, UITextViewDelegate {
public override init(frame: CGRect) {
let textViewFrame = CGRect(origin: CGPoint.zero, size: frame.size)        
let textView = UITextView(frame: textViewFrame)
self.textView = textView
// 伸縮の最小幅を指定
self.minHeight = frame.height
// 伸縮の最大幅を指定
self.maxHeight = 50
super.init(frame: frame)
self.textView.delegate = self
// UITextViewのスクロールはOffに
self.textView.scrollEnabled = false
self.addSubview(textView)
}
public var minHeight: CGFloat
public var maxHeight: CGFloat

public func textViewDidChange(textView: UITextView) {
// テキストの変更を受け取り、サイズを合わせる
self.fitToScrollView() // こちらのコードは下に貼ります。
}

private let textView: UITextView
}
次にサイズ計算の処理を追加します。
UITextViewの文字サイズ計算はsizeThatFitsを使用することで正確なサイズを取得することができます。
sizeThatFitsに渡す値は、
横幅 : NextGrowingTextViewの幅
縦幅 : 無限
と指定します。
private func measureTextViewSize() -> CGSize {
// 文字が全て表示されるサイズを取得
return textView.sizeThatFits(CGSize(width: self.bounds.width, height: CGFloat.infinity))
}
private func measureFrame(contentSize: CGSize) -> CGRect {

// NextGrowingTextViewの伸縮の限界値に応じたFrameを計算
let selfSize: CGSize
if contentSize.height < self.minHeight || !self.textView.hasText() {
selfSize = CGSize(width: contentSize.width, height: self.minHeight)
} else if self.maxHeight > 0 && contentSize.height > self.maxHeight {
selfSize = CGSize(width: contentSize.width, height: self.maxHeight)
} else {
selfSize = contentSize
}

var frame = self.frame
frame.size.height = selfSize.height
return frame
}
public override func intrinsicContentSize() -> CGSize {
// Autolayoutへ対応
// 初期化時とinvalidateIntrinsicContentSizeが呼ばれたタイミングで実行されます。
return self.measureFrame(self.measureTextViewSize()).size
}



最後に、textViewDidChangeの中で呼んでいるfitToScrollViewでは以下の処理を行います。
  • NextGrowingTextViewのサイズ変更
  • 改行を行った時のハンドリング
  • Autolayoutの対応
private func fitToScrollView() {

// 改行された時に下にスクロールを行うか判定
let scrollToBottom = self.contentOffset.y == self.contentSize.height - self.frame.height
// 全ての文字表示に必要なサイズを取得
let actualTextViewSize = self.measureTextViewSize()
let oldScrollViewFrame = self.frame

var frame = self.bounds
frame.origin = CGPoint.zero
frame.size.height = actualTextViewSize.height
// UITextViewのサイズを全ての文字が表示される大きさに変更する
self.textView.frame = frame
// UITextViewのサイズと自身のContentSizeを同じにする
self.contentSize = frame.size

let newScrollViewFrame = self.measureFrame(actualTextViewSize)

self.frame = newScrollViewFrame

if scrollToBottom {
self.scrollToBottom()
}

// Autolayoutへ対応
// 自分自身のサイズが変わったことをSuperviewに伝えます。
self.invalidateIntrinsicContentSize()
}
private func scrollToBottom() {
let offset = self.contentOffset
self.contentOffset = CGPoint(x: offset.x, y: self.contentSize.height - self.frame.height)
}
いくつかの細かい挙動のコードは省略していますが、
上記のコードでNextGrowingTextViewの実装は完了です。


補足ですが、NextGrowingTextViewの内部では下記の理由からAutolayoutを使用していません。
NextGrowingTextViewを載せるビューではAutolayoutは使用可能です。
  • UIScrollViewの中でのAutolayoutは一手間必要
  • iOSのバージョンによる影響を受ける可能性がある
それでは、次にStoryboardにてUIを作成していきます。
以下のスクリーンショットのようにビューを配置します。
Screen Shot 2016-01-21 at 10.21.15 PM
矢印で指しているUIViewのクラスをNextGrowingTextViewに設定します。
Screen_Shot_2016-01-21_at_10_21_15_PM
Autolayoutの制約は下記の図のように設定します。
設定しているのは各コンポーネントのマージンのみです。
NextGrowingTextViewに自動的に高さを変更してもらうため、高さと幅の制約は設定しません。
Screen Shot 2016-01-21 at 10.33.09 PM
最後に、入力部分がキーボードで隠れないようにするために、
UIViewControllerのUIViewと接しているBottomの制約をOutletでinputContainerViewBottomとして登録します。
Screen_Shot_2016-01-21_at_11_20_51_PM
ViewControllerには以下のコードを実装します。
override func viewDidLoad() {
super.viewDidLoad()
NSNotificationCenter.defaultCenter().addObserver(self, selector: "keyboardWillChangeFrame:", name: UIKeyboardWillChangeFrameNotification, object: nil)
}
dynamic func keyboardWillChangeFrame(notification: NSNotification) {
if let newHeight = notification.userInfo?[UIKeyboardFrameEndUserInfoKey]?.CGRectValue.size.height {

self.inputContainerViewBottom.constant = newHeight
}
}



以上でNextGrowingTextViewの完成です!
上記のサンプルもNextGrowingTextViewに置いてありますので、
是非動かしてみてください!
まとめ
UITextViewのサブクラスで実装するとiOSの様々なバグと闘うコードが増えてしまうのですが、

今回ご紹介した実装方法であれば、余計なコードを含まずにシンプルなコードだけでGrowingTextViewが実装できます。
SNS系のアプリではチャット画面の実装は多いかと思いますので、

是非参考にしてみてください。


NextGrowingTextViewへのPRもお待ちしております!
Like what you read? Give eureka_developers a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.