🚥

AndroidでiOS相当のCenterAlignedTopAppBarを実現する

2022/12/18に公開

Androidアプリのヘッダーに表示するタイトルを中央寄せするという対応をお願いされたんですが、いざ実装してみると大変だったので後世のために記事を残します。

仕様

  • ヘッダータイトルを中央寄せ
  • タイトルに使う文字列は可変
  • タイトルが領域に収まらなければ3点リーダーで省略する
  • ヘッダーの左にはボタンが1つ、右にはボタンが2つの非対称な構成
  • 古い実装なのでJava + Android View + Data Bindingを使っています

完成コード

ヘッダーのレイアウト

<androidx.constraintlayout.widget.ConstraintLayout
    android:id="@+id/constraintLayout"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize">

    <ImageView
        android:id="@+id/backButton"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="6dp"
        android:src="@drawable/arrow_back"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- アイコンの幅を調べて絶妙な大きさを入れてください -->
    <FrameLayout
        android:id="@+id/box"
        android:layout_width="60.712dp"
        android:layout_height="28dp"
        android:src="@drawable/item_container"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/backButton"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/title"
        android:text="@{title}"
        android:ellipsize="end"
        android:singleLine="true"
        android:lines="1"
        app:layout_constrainedWidth="true"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/icon1"
        app:layout_constraintStart_toEndOf="@id/box"
        app:layout_constraintTop_toTopOf="parent"
        tools:text="Header Text" />

    <ImageView
        android:id="@+id/icon1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/icon1"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/icon2"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageView
        android:id="@+id/icon2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:src="@drawable/icon2"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

タイトルを設定するための関数

public void setHeaderTitleText(final ActionBarBinding binding, final String title) {
    binding.title.setVisibility(View.INVISIBLE);
    binding.setHeaderTitle(title);

    ViewKt.doOnPreDraw(binding.title, v -> {
        final Layout titleLayout = binding.title.getLayout();
        final int titleLines = titleLayout.getLineCount();
        final ConstraintLayout constraintLayout = binding.constraintLayout;
        final ConstraintSet constraintSet = new ConstraintSet();
        constraintSet.clone(constraintLayout);

        // タイトルの省略が始まっていれば右寄せ、なければ中央寄せ
        if (titleLines > 0 && titleLayout.getEllipsisCount(titleLines - 1) > 0) {
            constraintSet.setHorizontalBias(R.id.title, 1.0F);
            constraintSet.applyTo(constraintLayout);
            binding.box.setVisibility(View.GONE);
        } else {
            constraintSet.setHorizontalBias(R.id.title, 0.5F);
            constraintSet.applyTo(constraintLayout);
            binding.box.setVisibility(View.VISIBLE);
        }

        binding.title.setVisibility(View.VISIBLE);
        return null;
    });
}

解説

TL;DR

透明な箱を差し込んで文字が溢れはじめたら非表示にして、文字の並びを右詰めにする

実装を大変にしているポイント

ヘッダーのレイアウトが非対称かつタイトルが可変

そもそもこの手の実装はAndroidには向いていません。
逆にiOSやFlutterだとなんの苦労もなく実装できてしまいます。。

Material3からアプリバーがタイトルのセンタリングに対応しているのでこれを使えばよくねとなるかもしれませんが、そう上手くはいきません。。
https://m3.material.io/components/top-app-bar/overview

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TopAppBar(titleText:String, scrollBehavior:TopAppBarScrollBehavior) {
    CenterAlignedTopAppBar(
        scrollBehavior = scrollBehavior,
        title = {
            Text(
                text = titleText,
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        },
        navigationIcon = {
            IconButton(onClick = {}) {
                Icon(
                    painter = rememberVectorPainter(image = Icons.Default.ArrowBack),
                    contentDescription = null
                )
            }
        },
        actions = {
            IconButton(onClick = {}) {
                Icon(
                    painter = rememberVectorPainter(image = Icons.Default.Search),
                    contentDescription = null,
                )
            }
            IconButton(onClick = {}) {
                Icon(
                    painter = rememberVectorPainter(image = Icons.Default.Notifications),
                    contentDescription = null
                )
            }
        },
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@PlayGroundBasePreview
@Composable
fun TopAppBarPreview() {
    val topAppBarState = rememberTopAppBarState()
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
    Column(modifier = Modifier) {
        TopAppBar(titleText = "a", scrollBehavior = scrollBehavior)
        TopAppBar(titleText = "aaaaaaaaa", scrollBehavior = scrollBehavior)
        TopAppBar(titleText = "aaaaaaaaaaaaaaaaaaaaa", scrollBehavior = scrollBehavior)
        TopAppBar(titleText = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", scrollBehavior = scrollBehavior)
    }
}

左に変な空間ができますね。。
非対称なレイアウトを想定していないように感じます。
悲しいですね。。

Flutterで実装するとこうです

AppBar(
  leading: const Icon(Icons.wb_sunny_outlined),
  title: const Text("Material3aaaaaaaaaaaaaaaaa"),
  titleSpacing: 0, // 変な空間がいいかんじに消えます
  centerTitle: true, // センタリングを強制
  actions: [
    IconButton(/* なんかいいかんじの実装 */),
    IconButton(/* なんかいいかんじの実装 */),
    IconButton(/* なんかいいかんじの実装 */),
  ],
);


とてもいいかんじですね。

純粋なAndridの実装で対応しようとするとViewを動的に操作するしかありません。

解説1:透明な箱を用意する

ヘッダーはアイコン+テキスト+アイコン+アイコンという非対称なレイアウトですが、
このままでは中央寄せの実装が面倒なのでアイコン+アイコン+テキスト+アイコン+アイコンという対称的なレイアウトに変更します。
透明な箱はFrameLayoutとかで適当に作ればいいです。

解説2:テキストが溢れだしたことを検知する

ある程度のテキスト量になるとellipsizeが機能しはじめるのでそのタイミングを検知します。

final Layout titleLayout = binding.title.getLayout();
final int titleLines = titleLayout.getLineCount();

if (titleLines > 0 && titleLayout.getEllipsisCount(titleLines - 1) > 0) {
    // ellipsizeしている
} else {
    // ellipsizeしていない
}

解説3:レイアウトを動的に変更する

ConstraintLayoutのAPIを使って中央寄せと右詰めを切り替えます

  final Layout titleLayout = binding.title.getLayout();
  final int titleLines = titleLayout.getLineCount();
+ final ConstraintLayout constraintLayout = binding.constraintLayout;
+ final ConstraintSet constraintSet = new ConstraintSet();
+ constraintSet.clone(constraintLayout);

  if (titleLines > 0 && titleLayout.getEllipsisCount(titleLines - 1) > 0) {
+     // 右詰め
+     constraintSet.setHorizontalBias(R.id.title, 1.0F);
+     constraintSet.applyTo(constraintLayout);
+     // 透明な箱は不要なので消します
+     binding.box.setVisibility(View.GONE);
  } else {
+     // 中央寄せ
+     constraintSet.setHorizontalBias(R.id.title, 0.5F);
+     constraintSet.applyTo(constraintLayout);
+     // 透明な箱を復活させる
+     binding.box.setVisibility(View.VISIBLE);
  }

解説4:レイアウト変更をよきタイミングで実行する

タイトルはData Bindingで動的に設定しているので設定直後、上記のコードを実行すると意図通りのレイアウト変更されないことがあります。(文字列がViewに反映される前で実行しているからだと思います)
そこで実行のタイミングはViewの内容が確定した 描画処理の実行直前 にしたいとおもいます。
core-ktxにはdoOnPreDrawという便利拡張関数が用意されているのでこれを使って安全に呼び出したいと思います。
Javaから呼ぶ場合はViewKtというやつをインポートして呼び出します。

// タイトルのちらつき防止
binding.title.setVisibility(View.INVISIBLE);
binding.setHeaderTitle(title);
ViewKt.doOnPreDraw(binding.title, v -> {
    final Layout titleLayout = binding.title.getLayout();
    final int titleLines = titleLayout.getLineCount();
    final ConstraintLayout constraintLayout = binding.constraintLayout;
    final ConstraintSet constraintSet = new ConstraintSet();
    constraintSet.clone(constraintLayout);

    // タイトルの省略が始まっていれば右寄せ、なければ中央寄せ
    if (titleLines > 0 && titleLayout.getEllipsisCount(titleLines - 1) > 0) {
        constraintSet.setHorizontalBias(R.id.title, 1.0F);
        constraintSet.applyTo(constraintLayout);
        binding.box.setVisibility(View.GONE);
    } else {
        constraintSet.setHorizontalBias(R.id.title, 0.5F);
        constraintSet.applyTo(constraintLayout);
        binding.box.setVisibility(View.VISIBLE);
    }

    // タイトルのちらつき防止
    binding.title.setVisibility(View.VISIBLE);
    return null; // () -> UnitをJavaから呼ぶ場合nullなどを返す必要があります
});

これでどんな長さのテキストでもいいかんじにセンタリングしてくれるようになります🎉🎉🎉

気が向いたらKotlin版とかCompose版を書きますw

GitHubで編集を提案
カラビナテクノロジー デベロッパーブログ

Discussion