AndroidでiOS相当のCenterAlignedTopAppBarを実現する
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からアプリバーがタイトルのセンタリングに対応しているのでこれを使えばよくねとなるかもしれませんが、そう上手くはいきません。。
@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
株式会社 カラビナテクノロジーは「命綱や支点を素早く確実に繋ぐカラビナ。そんなカラビナのような役割をテクノロジーで実現したい」という想いのもと、福岡で設立。 主にシステム開発・アプリ開発・ Webサイト制作を行っています。採用情報→karabiner.tech/recruit/requirements/
Discussion