【Android】画像をカルーセル表示するときにハマった話

RecyclerViewを使うと簡単に画像をカルーセル表示することができます。
ですが、ImageViewの扱いでハマった部分があったので、その際に調査した内容をまとめました。

# TL;DR
ImageViewの**`AdjustViewBounds`に`true`をセットする**ことで、ImageViewのサイズを画像サイズに合わせて調整することができます。

# 前提条件
カルーセル、画像のサイズおよび表示方法に関する条件は以下の通りです。

1. カルーセルの高さは固定とする
2. カルーセルに表示する各画像のサイズはすべて異なる可能性がある
3. 各画像の高さはカルーセルの高さと一致するように拡大縮小する
4. 各画像のアスペクト比は維持する
5. 各画像はクロップしない

# ハマった内容
以前作成した[ScaleTypeと表示画像の対応表](https://qiita.com/tkhs0604/items/76bd3c979ae3c0c1570c)を参考に、`ScaleType`は一旦`FIT_CENTER`(デフォルト値のためxml上の指定なし)、また`layout_width`は`wrap_content`、`layout_height`は`100dp`としました。

RecyclerViewの各要素に表示する画面のxmlは以下の通りです。

```xml:item_main.xml
<?xml version="1.0" encoding="utf-8"?>
<ImageView
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/imageView"
  android:layout_width="wrap_content"
  android:layout_height="100dp" />
```

正直どの`ScaleType`も今回の条件を満たさないと感じつつも、`layout_width`を`wrap_content`にすることで、`FIT_CENTER`であれば画像の高さをカルーセルの高さと一致させ、それに合わせて画像全体が含まれるようにアスペクト比を維持しながら幅を調整してくれるのでは？というわずかな希望を抱きながら試してみました。

結果は以下のようになりました。案の定、希望通りの表示になりませんでした。
![before.png](https://qiita-image-store.s3.amazonaws.com/0/302946/481f2a9c-4c65-923d-2a3d-5e785a173b99.png)
これを見て、画像が小さいときの結果(左)は納得できるのですが、画像が大きいときの結果(右)に関しては何で左右に余白ができるのかが理解できませんでした。

# 解決方法
冒頭でも触れましたが、ImageViewの`AdjustViewBounds`を`true`にすることで解決できました。これは文字通り「Viewの境界を調整する」ための属性です。
公式ドキュメントは[こちら](https://developer.android.com/reference/android/widget/ImageView.html#attr_android:adjustViewBounds)にあります。

`AdjustViewBounds`を`true`にすると、結果は以下のようになりました。上が先ほどの結果(デフォルト値は`false`)で、下が今回の結果です。
![after.png](https://qiita-image-store.s3.amazonaws.com/0/302946/5a5bf500-6a6a-537e-8bd5-71d5dc5e310b.png)
これで、ImageViewのサイズを画像サイズに合わせて調整することができました。

# AdjustViewBoundsをセットしたときの内部処理
結果としてはこれで問題ないのですが、画像が大きいときの結果に関してはこれだけでは理解ができなかったので、ImageViewの処理を追ってみました。
ImageView.javaのソースコードは[こちら](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/widget/ImageView.java)にあります。

Androidにおいて、Viewが表示されるまでの大まかな流れは以下の通りになります。今回の肝となるのは1.の`onMeasure()`です。

1. サイズを決める(`onMeasure()`)
2. 場所を決める(`onLayout()`)
3. 描画する(`onDraw()`)

これに関しては[こちらの記事](https://www.ecoop.net/memo/archives/android_lifecycle_of_view.html)を参考にさせていただきました。
<br>
では、ここからはImageViewの`onMeasure()`の処理を追っていきます。

最初は各変数を宣言しているだけだったので割愛します。
以下の処理が1つ目のポイントになります。

```java:ImageView.java#LL.1088-1089
final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
```

`widthMeasureSpec`と`heightMeasureSpec`は`onMeasure()`の引数で、そこから`MeasureSpec`の`Mode`を取得しています。
ただ、`widthMeasureSpec`と`heightMeasureSpec`がどのように決定されるのかは正直よく分かりませんでした。

`MeasureSpec`は親Viewから子Viewに対して課される制約を表しており、以下の3種類の`Mode`があります。公式ドキュメントは[こちら](https://developer.android.com/reference/android/view/View.MeasureSpec)にあります。

|MeasureSpec.Mode|制約条件|
|:--|:--|
|UNSPECIFIED|親Viewによって子Viewのサイズが決定されない|
|EXACTLY|親Viewによって子Viewの正確なサイズが決定される|
|AT_MOST|親Viewによって子Viewの最大のサイズが決定される|

したがって、`widthSpecMode`は`MeasureSpec.UNSPECIFIED`、`heightSpecMode`は`MeasureSpec.EXACTLY`となります。

<br>
そして、以下の処理が2つ目のポイントになります。
`AdjustViewBounds`が`true`の場合のみ以下の処理が行われます。

```java:ImageView.java#LL.1104-1109
if (mAdjustViewBounds) {
  resizeWidth = widthSpecMode != MeasureSpec.EXACTLY;
  resizeHeight = heightSpecMode != MeasureSpec.EXACTLY;
  desiredAspect = (float) w / (float) h;
}
```

`resizeWidth`と`resizeHeight`は幅・高さのリサイズを行うか否かを制御する変数です。
`desiredAspect`は画像のアスペクト比(`w`は幅、`h`は高さ)を表す変数です。

今回の場合、`widthSpecMode`は`MeasureSpec.UNSPECIFIED`、`heightSpecMode`は`MeasureSpec.EXACTLY`なので、`AdjustViewBounds`が`true`のときは`resizeWidth`のみが`true`となります。

<br>
そして、以下の処理が3つ目のポイントになります。
長いので詳細は省略しますが、おおむね以下のような処理が行われています。

1. `AdjustViewBounds`が`true`のとき  
ImageViewのサイズを画像サイズに合わせて調整する
2. `AdjustViewBounds`が`false`のとき  
ImageViewの幅を画像の幅と一致するよう調整する

```java:ImageView.java#LL.1120-1190
int widthSize;
int heightSize;
if (resizeWidth || resizeHeight) {
  // Get the max possible width given our constraints
  widthSize = resolveAdjustedSize(w + pleft + pright, mMaxWidth, widthMeasureSpec);
  // Get the max possible height given our constraints
  heightSize = resolveAdjustedSize(h + ptop + pbottom, mMaxHeight, heightMeasureSpec);
  if (desiredAspect != 0.0f) {
    // See what our actual aspect ratio is
    final float actualAspect = (float)(widthSize - pleft - pright) / (heightSize - ptop - pbottom);
    if (Math.abs(actualAspect - desiredAspect) > 0.0000001) {
      boolean done = false;
      // Try adjusting width to be proportional to height
      if (resizeWidth) {
        int newWidth = (int)(desiredAspect * (heightSize - ptop - pbottom)) + pleft + pright;
        // Allow the width to outgrow its original estimate if height is fixed.
        if (!resizeHeight && !sCompatAdjustViewBounds) {
          widthSize = resolveAdjustedSize(newWidth, mMaxWidth, widthMeasureSpec);
        }
        if (newWidth <= widthSize) {
          widthSize = newWidth;
          done = true;
        }
      }
      // Try adjusting height to be proportional to width
      if (!done && resizeHeight) {
        ...
      }
    }
  }
} else {
  w += pleft + pright;
  h += ptop + pbottom;
  w = Math.max(w, getSuggestedMinimumWidth());
  h = Math.max(h, getSuggestedMinimumHeight());
  widthSize = resolveSizeAndState(w, widthMeasureSpec, 0);
  heightSize = resolveSizeAndState(h, heightMeasureSpec, 0);
}
setMeasuredDimension(widthSize, heightSize);
```

<br>
ここで、else句の最後にある`resolveSizeAndState()`が**`MeasureSpec.UNSPECIFIED`のときは引数に指定した画像サイズそのものを返していた**ので、ImageViewの幅が画像の幅と同じサイズで表示されるということが分かりました。スッキリ。
View.javaのソースコードは[こちら](https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/View.java)にあります。

```java:View#LL.23463-23483
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) {
  final int specMode = MeasureSpec.getMode(measureSpec);
  final int specSize = MeasureSpec.getSize(measureSpec);
  final int result;
  switch (specMode) {
    case MeasureSpec.AT_MOST:
      if (specSize < size) {
        result = specSize | MEASURED_STATE_TOO_SMALL;
      } else {
        result = size;
      }
      break;
    case MeasureSpec.EXACTLY:
      result = specSize;
      break;
    case MeasureSpec.UNSPECIFIED:
    default:
      result = size;
  }
  return result | (childMeasuredState & MEASURED_STATE_MASK);
}
```

# サンプルアプリ
上記の画像キャプチャを撮影したアプリです。
[GitHub - AdjustViewBoundsChecker](https://github.com/tkhs0604/AdjustViewBoundsChecker)