読者です 読者をやめる 読者になる 読者になる

UITextField, UITextView がキーボードで隠れないようにする

環境:iOS Deployment Target 7.1

UITextField、UITextView は入力フィールドにフォーカスが当たると画面下からキーボードが現れるが、この時に入力フィールドが画面下側にある場合にキーボードの裏に隠れてしまう問題がある。

やりたいこと

1画面に複数の UITextField と 複数の UITextView があった場合でも入力フィールドがキーボードに隠れないようにしたい。

対応方法

UITextField や UITextView をスクロールビューに乗せる。そしてキーボードの上げ下げに合わせてスクロールビューをスクロールさせる(+余白の追加)というやり方でできた。

流れ

  • UIScrollView を作成

  • その上に UITextField, UITextView を配置

  • viewWillAppear でキーボード表示前のイベントUIKeyboardWillShowNotificationとキーボード非表示前のイベントUIKeyboardWillHideNotificationを登録

  • 入力フィールドにフォーカスが当たりキーボード表示時UIKeyboardWillShowNotificationに対象の入力フィールドの bottom とキーボードの top を比較してキーボードの裏に隠れるようだったら以下の処理を行う

  • キーボードが入力フィールドに被る分の高さを算出し、その高さ分を UIScrollView の contentInset と scrollIndicatorInsets の bottom にセットして余白スペースを追加する

  • UIScrollView の contentOffset にも高さ分をセットしてスクロールで移動させる

  • キーボード非表示時UIKeyboardWillHideNotificationには contentInset と scrollIndicatorInsets の bottom に 0 をセットする。もしインセット部分が画面表示内に含まれていたら、戻りの contentOffset を設定せずとも自動的に削除分、上にスクロールしてくれる

  • viewWillDisappear で登録していたキーボード表示・非表示時の Notification を削除

サンプル画面

青い枠が実際に表示されている範囲。

f:id:xyk:20141002183403p:plain

動作画面

f:id:xyk:20141002201156g:plain

サンプルコード

#import "ViewController.h"

@interface ViewController ()
<
UIScrollViewDelegate,
UITextFieldDelegate,
UITextViewDelegate
>
@end

@implementation ViewController
{
    UIScrollView *_scrollView;
    UITextField *_activeField;
    UITextView *_activeTextView;
}

- (void)viewDidLoad
{
    [super viewDidLoad];

    // スクロールできる縦長の画面を UIScrollView で作成する
    _scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
    _scrollView.backgroundColor = [UIColor lightGrayColor];
    [_scrollView setContentSize: CGSizeMake(_scrollView.bounds.size.width, 1000)];

    // スクロールがわかりやすいように背景色をグラデーションにする
    CAGradientLayer *gLayer = [CAGradientLayer layer];
    gLayer.frame = CGRectMake(_scrollView.bounds.origin.x,
                              _scrollView.bounds.origin.y,
                              _scrollView.contentSize.width,
                              _scrollView.contentSize.height);
    gLayer.colors = @[(id)[UIColor whiteColor].CGColor, (id)[UIColor blackColor].CGColor];
    [_scrollView.layer insertSublayer:gLayer atIndex:0];

    [self.view addSubview:_scrollView];

    // UITextField, UITextView を配置
    [_scrollView addSubview:[self createLabel:CGRectMake(10, 260, 80, 30) labelText:@"TextField1"]];
    [_scrollView addSubview:[self createTextField:CGRectMake(100, 260, 200, 30)]];

    [_scrollView addSubview:[self createLabel:CGRectMake(10, 300, 80, 30) labelText:@"TextField2"]];
    [_scrollView addSubview:[self createTextField:CGRectMake(100, 300, 200, 30)]];

    [_scrollView addSubview:[self createLabel:CGRectMake(10, 340, 80, 30) labelText:@"TextView1"]];
    [_scrollView addSubview:[self createTextView:CGRectMake(100, 340, 200, 60)]];

    [_scrollView addSubview:[self createLabel:CGRectMake(10, 410, 80, 30) labelText:@"TextView2"]];
    [_scrollView addSubview:[self createTextView:CGRectMake(100, 410, 200, 60)]];

    [_scrollView addSubview:[self createLabel:CGRectMake(10, 890, 80, 30) labelText:@"textField3"]];
    [_scrollView addSubview:[self createTextField:CGRectMake(100, 890, 200, 30)]];

    [_scrollView addSubview:[self createLabel:CGRectMake(10, 930, 80, 30) labelText:@"TextView3"]];
    [_scrollView addSubview:[self createTextView:CGRectMake(100, 930, 200, 60)]];
}

// UITextField の Return キーをタップ時にキーボードを隠す
- (BOOL)textFieldShouldReturn:(UITextField *)textField
{
    [textField resignFirstResponder];
    return YES;
}

// UITextView のキーボードを隠す
- (void)closeKeyboardForTextView
{
    [_activeTextView resignFirstResponder];
}

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

    // キーボード表示・非表示時のイベント登録
    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWillShown:)
                                                 name:UIKeyboardWillShowNotification object:nil];

    [[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(keyboardWillHidden:)
                                                 name:UIKeyboardWillHideNotification object:nil];
}

- (void)viewWillDisappear:(BOOL)animated
{
    // キーボード表示・非表示時のイベント削除
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillShowNotification object:nil];
    [[NSNotificationCenter defaultCenter] removeObserver:self name:UIKeyboardWillHideNotification object:nil];
}

- (void)keyboardWillShown:(NSNotification *)notification
{
    // キーボードの top を取得する
    NSDictionary *userInfo = [notification userInfo];
    CGRect keyboardRect = [[userInfo objectForKey:UIKeyboardFrameEndUserInfoKey] CGRectValue];
    keyboardRect = [_scrollView convertRect:keyboardRect fromView:nil]; // 座標変換。お約束らしいがよくわかっていない。。

    CGRect textFieldRect;
    if (_activeTextView != nil) {
        textFieldRect = _activeTextView.frame;
    }
    if (_activeField != nil) {
        textFieldRect = _activeField.frame;
    }

    // キーボードに隠れない場合は何もしない
    if (CGRectGetMaxY(textFieldRect) < CGRectGetMinY(keyboardRect)) {
        return;
    }

    CGFloat nowOffsetY = _scrollView.contentOffset.y;

    // スクロールさせる距離を算出
    CGFloat offsetY = CGRectGetMaxY(textFieldRect) - CGRectGetMinY(keyboardRect);

    // scrollView の contentInset と scrollIndicatorInsets の bottom に追加
    UIEdgeInsets contentInsets = UIEdgeInsetsMake(0.0, 0.0, offsetY, 0.0);
    _scrollView.contentInset = contentInsets;
    _scrollView.scrollIndicatorInsets = contentInsets;

    // 移動後のオフセット算出
    CGPoint scrollPoint = CGPointMake(0.0, nowOffsetY + offsetY);

    // キーボードアニメーションと同じ間隔、速度になるように設定
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationDuration:[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]];
    [UIView setAnimationCurve:[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue]];
    [UIView setAnimationBeginsFromCurrentState:YES];

    // 移動後のオフセット設定
    _scrollView.contentOffset = scrollPoint;

    // 表示アニメーション開始
    [UIView commitAnimations];
}

- (void)keyboardWillHidden:(NSNotification *)notification
{
    // キーボードアニメーションと同じ間隔、速度になるように設定
    [UIView beginAnimations:nil context:NULL];
    [UIView setAnimationDuration:[notification.userInfo[UIKeyboardAnimationDurationUserInfoKey] doubleValue]];
    [UIView setAnimationCurve:[notification.userInfo[UIKeyboardAnimationCurveUserInfoKey] integerValue]];
    [UIView setAnimationBeginsFromCurrentState:YES];

    // インセットを 0 にする
    UIEdgeInsets contentInsets = UIEdgeInsetsZero;
    _scrollView.contentInset = contentInsets;
    _scrollView.scrollIndicatorInsets = contentInsets;

    // 非表示アニメーション開始
    [UIView commitAnimations];
}

// UITextField にフォーカスが当たった時
- (void)textFieldDidBeginEditing:(UITextField *)textField
{
    _activeField = textField;
}

// UITextField のフォーカスが外れた時
- (void)textFieldDidEndEditing:(UITextField *)textField
{
    if (textField == _activeField) {
        _activeField = nil;
    }
}

// UITextView にフォーカスが当たった時
- (BOOL)textViewShouldBeginEditing:(UITextView *)textView
{
    _activeTextView = textView;
    return YES;
}

// UITextView のフォーカスが外れた時
- (BOOL)textViewShouldEndEditing:(UITextView *)textView
{
    if (textView == _activeTextView) {
        _activeTextView = nil;
    }
    return YES;
}

// 入力フィールドのラベル作成
- (UILabel *)createLabel:(CGRect)frame labelText:(NSString *)text
{
    UILabel *label = [[UILabel alloc] init];
    label.frame = frame;
    label.backgroundColor = [UIColor clearColor];
    label.textColor = [UIColor whiteColor];
    label.text = text;
    return label;
}

// UITextField 作成
- (UITextField *)createTextField:(CGRect)frame
{
    UITextField* textField = [[UITextField alloc] initWithFrame:frame];
    textField.borderStyle = UITextBorderStyleRoundedRect;
    textField.returnKeyType = UIReturnKeyDone;
    textField.delegate = self;
    return textField;
}

// UITextView 作成
- (UITextView *)createTextView:(CGRect)frame
{
    UITextView *textView = [[UITextView alloc] initWithFrame:frame];
    textView.delegate = self;
    textView.inputAccessoryView = [self createAccessoryView];
    return textView;
}

// 閉じるボタンを配置したアクセサリビュー作成
- (UIView *)createAccessoryView
{
    UIView *accessoryView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, 40)];
    accessoryView.backgroundColor = [UIColor darkGrayColor];
    UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeRoundedRect];
    closeBtn.backgroundColor = [UIColor lightGrayColor];
    closeBtn.frame = CGRectMake(self.view.bounds.size.width - 80, 5, 80, 30);
    [closeBtn setTitle:@"閉じる" forState:UIControlStateNormal];
    [closeBtn addTarget:self action:@selector(closeKeyboardForTextView) forControlEvents:UIControlEventTouchUpInside];
    [accessoryView addSubview:closeBtn];
    return accessoryView;
}

@end

UIScrollView の contentInset と scrollIndicatorInsets について

図で見るとわかりやすい。

f:id:xyk:20140922193643p:plain

f:id:xyk:20141002183417p:plain

ただし、contentInsetの値を変更すると、ScrollViewがスクロールインジケータを表示する場合に予 期せぬ副次的な影響があります。ユーザが画面の最上部または最下部へコンテンツをドラッグする と、スクロールインジケータが、contentInsetで定義された領域内の領域に表示されているすべて のコンテンツ(たとえば、ナビゲーションコントロールやツールバーなど)に重ねてスクロールして しまいます。 これを修正するには、scrollIndicatorInsetsプロパティを設定する必要があります。contentInset プロパティと同様に、scrollIndicatorInsetsプロパティはUIEdgeInsets構造体として定義されて います。垂直インセット値を設定すると、垂直スクロールのインジケータがそのインセット値を超え て表示されるのを制限します。またこれにより、水平スクロールのインジケータがcontentInsetの 矩形領域の外側に表示されるようになります。 scrollIndicatorInsetsプロパティも設定せずにcontentInsetを変更すると、スクロールインジ ケータがNavigation Controllerおよびツールバーに重ねて描画され、望まない結果となります。しか し、scrollIndicatorInsetsの各値をcontentInsetの値に一致するように設定すると、この状況は 解消されます。 https://developer.apple.com/jp/devcenter/ios/library/documentation/UIScrollView_pg.pdf

その他

  • 変換候補表示時もキーボード系の Notification が通知される。スクロール位置を調整する時にこの高さ分も事前に含んでおいた方が余計なスクロールをせずにすむ。上のサンプルでは含んでないけど。

  • UIKeyboardWillShowNotification はキーボード表示前、 UIKeyboardDidShowNotification はキーボード表示後でタイミングが違うので注意。はじめは気付かずにDidの方を使っていてキーボードの表示からワンテンポ遅れてスクロールしてた。

    • UIKeyboardWillHideNotification
    • UIKeyboardDidHideNotification
    • UIKeyboardWillShowNotification
    • UIKeyboardDidShowNotification
  • ナビゲーションバーを使う場合を考慮していない。インセットに UIEdgeInsetsZero を設定するのはマズい。

追記

hackiftekhar/IQKeyboardManager
https://github.com/hackiftekhar/IQKeyboardManager

これスゴそう。デモをちょっと試したけど UITextField、UITextView の使い方をすべて網羅しているかんじ。swift 対応もしてる。