ソフトキーボード出現時に画面内に残るエリアを変更するにはrequestRectangleOnScreenをoverrideする

ScrollViewなどスクロール可能なViewの上にあるEditTextにフォーカスをあて、ソフトキーボードで入力するとき、日本語の場合は特に変換候補が複数行にわたって表示されるなど、表示可能なエリアが入力に応じて変動します。
デフォルトのEditTextを使っている場合、そのEditTextが画面内に収まるようにスクロールしてくれるのですが、入力文字数を表示するカウンターなど、EditTextの下にある要素も見える位置までスクロールして欲しい！ということがあるかと思います。
TextInputLayoutとTextInputEditTextを使えばそのような動作になります。
同様の動作を他の要素でもやりたい。という場合どうすれば良いかの説明です。

|ScrollViewの上のEditTextを配置|入力中はEditTextの範囲が画面内に収まる位置にスクロールされる|EditTextだけでなく、下のテキストまで見えて欲しい|
|--|--|--|
|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/a82089fb-4869-779a-5823-042dd4131756.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/ebd6f994-dc57-ae69-aec2-eb23ee718d26.png)|![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155171/6939559b-26c7-56d7-af2a-eb6bd54f4575.png)|

# 親Viewの領域が画面内に収まるようスクロールするEditText

EditTextへの入力中、入力しているところが見えるようにスクロールされる仕組みは`requestRectangleOnScreen`で実装されています。EditTextを拡張したクラスを作成し、`requestRectangleOnScreen`をoverrideすることでこの動作を変更することができます。
上記画像の例では、EditTextとその下のTextViewを囲うViewGroupがあり、そのViewGroupが見える位置にスクロールするように実装するには以下のようにします。

```kotlin
class MyEditText @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.editTextStyle,
) : AppCompatEditText(context, attrs, defStyleAttr) {
    override fun requestRectangleOnScreen(rectangle: Rect?, immediate: Boolean): Boolean {
        val parentView = (parent as? View)
        return if (parentView == null) {
            super.requestRectangleOnScreen(rectangle, immediate)
        } else {
            val rect = Rect()
            parentView.getDrawingRect(rect)
            parentView.requestRectangleOnScreen(rect, immediate)
        }
    }
}
```
`requestRectangleOnScreenはTextView`の`onPreDraw`/`updateAfterEdit`から`bringPointIntoView`を経由してコールされており、フォーカスの変化や入力テキストの変化時にこのViewを画面内に収めるためにコールされます。引数のrectangleには入力中のテキストの領域などがはいっています。

ここで、parentViewのDrawingRect、つまり、そのView自身の相対座標で表示されている領域を表すRectを取得し、その値をそのViewの`requestRectangleOnScreen`に渡します。こうすると、そのViewが画面内に収まるようにスクロールが行われます。

親レイアウトにしているのは単にこのEditTextからアクセスしやすいというだけなので、別途引数などで渡すことができるのであれば、親子関係のないViewでも問題ありません。そのViewが見える範囲へスクロールされます。

以上です

---

# requestRectangleOnScreenってなにやってるの？

やり方としてはもう終わりなのですが、`requestRectangleOnScreen`ってどういう仕組みになっているか？というか引数の意味も分からなかったので調べました。

`requestRectangleOnScreen`の実装はViewで行われています。

```java:View.java
public boolean requestRectangleOnScreen(Rect rectangle, boolean immediate) {
    if (mParent == null) {
        return false;
    }

    View child = this;

    RectF position = (mAttachInfo != null) ? mAttachInfo.mTmpTransformRect : new RectF();
    position.set(rectangle);

    ViewParent parent = mParent;
    boolean scrolled = false;
    while (parent != null) {
        rectangle.set((int) position.left, (int) position.top,
                (int) position.right, (int) position.bottom);

        scrolled |= parent.requestChildRectangleOnScreen(child, rectangle, immediate);

        if (!(parent instanceof View)) {
            break;
        }

        // move it from child's content coordinate space to parent's content coordinate space
        position.offset(child.mLeft - child.getScrollX(), child.mTop -child.getScrollY());

        child = (View) parent;
        parent = child.getParent();
    }

    return scrolled;
}
```

ちょっとややこしいですが、やっていることは、親Viewにたいして、子View相対座標でのrectangleを指定して `requestChildRectangleOnScreen` をコール、そしてrectangleを親Viewの相対座標に変更した上で、さらにその親にと親がViewでなくなるまで繰り返します。

`requestChildRectangleOnScreen`の実装を見てみます。

```java:ViewGroup.java
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
    return false;
}
```

はい、ViewGroupはスクロール機能を持っていないため、空実装です。FrameLayoutやLinearLayoutも同様です。
ではScrollViewの実装を見てみましょう。

```java:ScrollView.java
@Override
public boolean requestChildRectangleOnScreen(View child, Rect rectangle,
        boolean immediate) {
    // offset into coordinate space of this scroll view
    rectangle.offset(child.getLeft() - child.getScrollX(),
            child.getTop() - child.getScrollY());

    return scrollToChildRect(rectangle, immediate);
}
```

子Viewの相対座標で渡されたrectangleを自身の相対座標に変換して、`scrollToChildRect`をコールしていますね。
引数のrectangleをワーキングメモリとして使ってしまっていますが、`requestRectangleOnScreen`の実装を見ればわかるとおり、中身を書き換えてしまっても次のループでリセットされるようになっているため、外部に影響はありません。

`scrollToChildRect` を見てみましょう。

```java:ScrollView.java
private boolean scrollToChildRect(Rect rect, boolean immediate) {
    final int delta = computeScrollDeltaToGetChildRectOnScreen(rect);
    final boolean scroll = delta != 0;
    if (scroll) {
        if (immediate) {
            scrollBy(0, delta);
        } else {
            smoothScrollBy(0, delta);
        }
    }
    return scroll;
}
```

`computeScrollDeltaToGetChildRectOnScreen`でrectからスクロール量を計算して、自身をスクロールさせています。

`computeScrollDeltaToGetChildRectOnScreen`を見てみましょう。

```java:ScrollView.java
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
    if (getChildCount() == 0) return 0;

    int height = getHeight();
    int screenTop = getScrollY();
    int screenBottom = screenTop + height;

    int fadingEdge = getVerticalFadingEdgeLength();

    // leave room for top fading edge as long as rect isn't at very top
    if (rect.top > 0) {
        screenTop += fadingEdge;
    }

    // leave room for bottom fading edge as long as rect isn't at very bottom
    if (rect.bottom < getChildAt(0).getHeight()) {
        screenBottom -= fadingEdge;
    }

    int scrollYDelta = 0;

    if (rect.bottom > screenBottom && rect.top > screenTop) {
        // need to move down to get it in view: move down just enough so
        // that the entire rectangle is in view (or at least the first
        // screen size chunk).

        if (rect.height() > height) {
            // just enough to get screen size chunk on
            scrollYDelta += (rect.top - screenTop);
        } else {
            // get entire rect at bottom of screen
            scrollYDelta += (rect.bottom - screenBottom);
        }

        // make sure we aren't scrolling beyond the end of our content
        int bottom = getChildAt(0).getBottom();
        int distanceToBottom = bottom - screenBottom;
        scrollYDelta = Math.min(scrollYDelta, distanceToBottom);

    } else if (rect.top < screenTop && rect.bottom < screenBottom) {
        // need to move up to get it in view: move up just enough so that
        // entire rectangle is in view (or at least the first screen
        // size chunk of it).

        if (rect.height() > height) {
            // screen size chunk
            scrollYDelta -= (screenBottom - rect.bottom);
        } else {
            // entire rect at top
            scrollYDelta -= (screenTop - rect.top);
        }

        // make sure we aren't scrolling any further than the top our content
        scrollYDelta = Math.max(scrollYDelta, -getScrollY());
    }
    return scrollYDelta;
}
```

ちょと長いメソッドですがやっていることはシンプルですね。fadingEdigがあればそれをよけるように、指定されたrect範囲が画面内に収まる最短のスクロール量を計算しています。
指定された領域の高さが表示可能範囲より高い場合、画面下からは上端が収まるまで、画面上からは下端が収まるまでと、最短のスクロール量になっていますね。

同様にRecyclerViewなども`requestChildRectangleOnScreen`が実装されていて、指定領域が見えるような位置までのスクロールを行います。

以上です