メリークリスマス。この記事は、湯婆婆 Advent Calendar 2020の4日目です。
賛辞と謝辞
まずは@Nemesisさんに賛辞と謝辞を送ります。
Javaで湯婆婆を実装してみるという記事で、このようなムーヴメントを興こせるだなんて、素晴らしいです。
呼んでいる胸のどこか奥でいつも心踊る夢を見たい気分にさせていただきました。
私にこのような記事を投稿させてもらえる機会を与えていただき、ありがとうございます。
先日、Kotlinで湯婆婆を実装という記事を投稿させていただきました。
でも私の中では心のどこかでAndroidアプリにしたい、という想いが残っていて。
ということで作ったみた次第です。しかし今回はKotlinではなく、Javaで1。
やりたいこと
まずは、こちらの名シーンをご覧ください。
GIFMAGAZINE:紙に書いた名前がふわーっと浮いてどこかへ行ってしまうGIF画像 2
荻野千尋が書いた字が、ふわーっと浮いてどこかへ行ってしまうシーンです。これを実現したいです。
完成したアプリ
名前を入力してボタンをクリックすると、1字を残してふわーっと浮いてどこかへ行ってしまうアプリです。
なお、このアニメーションGIFを作るにあたって、うっかり荻野千尋を、誤って「萩」にしてしまっていました。これはオギノさんハギノさんあるあるなのかもしれませんが、実はこれが後々に恐ろしいことにつながっていきます...詳しくは後述をご覧ください(勿体ぶってすみません)。
コード
とりあえずまずは、ずらずらずら~っと掲載しておきます。
可能な限りコメントを付けましたので、日本語と照らし合わしながら解読していただければ幸いです。
ポイント解説は後ほど。
画面を実現するActivityクラスです。というか、Javaのプログラムはこれ1個だけのアプリです。
public class MainActivity extends AppCompatActivity {
// もはやこのRandomオブジェクト自体が湯婆婆なんじゃないのかと思ふ
private Random random = new Random();
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
// ここに入力した名前(TextView)を埋め込みます
LinearLayout nameArea = findViewById(R.id.name_area);
// 「契約する」ボタンクリック
Button keiyakuBtn = findViewById(R.id.keiyaku_button);
keiyakuBtn.setOnClickListener(v -> {
EditText editText = findViewById(R.id.name);
// EditTextクリックで出てきたソフトウェアキーボードを引っ込めさせる
InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(editText.getWindowToken(), 0);
editText.clearFocus();
// 2度目以降を考慮してこの名前表示領域から全子ビューを削除
nameArea.removeAllViews();
// 入力欄(EditText)から入力文字列(String)を取得
String name = editText.getText().toString();
// 配列としてバラバラにします("荻野千尋"なら要素数4の配列)
String[] names = new String[name.length()];
for (int i = 0; i < name.length(); i++) {
names[i] = String.valueOf(name.charAt(i));
}
// 運命の分かれ道!さあ、どの字だけが残るのか?!
int num = random.nextInt(names.length);
String newName = names[num];
// String配列の要素数と同数のTextView配列オブジェクトを生成
TextView[] texts = new TextView[names.length];
// LayoutInflaterは、XMLで定義した<TextView>を、JavaのTextViewオブジェクトにしてくれます
LayoutInflater layoutInflater = LayoutInflater.from(this);
for (int i = 0; i < names.length; i++) {
texts[i] = (TextView) layoutInflater.inflate(R.layout.name_entity, null);
// 各TextView1個ずつに名前1字を設定
texts[i].setText(names[i]);
if (i != num) {
// 選択されなかった字以外は、ふわーっと浮いてどこかへ行ってしまうアニメーションをセット
huwa_xtutouiteAnimation(texts[i]);
}
// 名前エリアに埋め込みます
nameArea.addView(texts[i]);
}
// 「フン~」セリフ出現
TextView hun = findViewById(R.id.hun);
hun.setText(String.format(getResources().getString(R.string.hun), name));
hun.setVisibility(View.VISIBLE);
// 「いいかい~」セリフ出現
TextView iikai = findViewById(R.id.iikai);
iikai.setText(String.format(getResources().getString(R.string.iikai), newName, newName, newName));
iikai.setVisibility(View.VISIBLE);
});
}
/**
* ふわーっと浮いてどこかへ行ってしまうアニメーション
*
* @param view ふわーっと浮いてどこかへ行かせてしまいたいView
*/
private void huwa_xtutouiteAnimation(final View view) {
// 様々な挙動をするアニメーションを複合化するには、ObjectAnimatorが便利のようだ
ObjectAnimator transY, transX, scaleX, alpha;
List<Animator> animatorList = new ArrayList<>();
// アニメーション時間もランダムに5~10秒に演出
int durationTY = (random.nextInt(6) + 5) * 1000;
int durationTX = (random.nextInt(6) + 5) * 1000;
int durationSX = (random.nextInt(6) + 5) * 1000;
// 上昇"translationY"
transY = ObjectAnimator.ofFloat(view, View.TRANSLATION_Y.getName(), 0.0f, -1000.0f);
transY.setDuration(durationTY);
animatorList.add(transY);
// 左から右へ移動"translationX"
transX = ObjectAnimator.ofFloat(view, View.TRANSLATION_X.getName(), view.getX(), view.getX() - 100.0f, view.getX() + 100.0f);
transX.setDuration(durationTX);
animatorList.add(transX);
// 幅の伸び縮み"scaleX"
scaleX = ObjectAnimator.ofFloat(view, View.SCALE_X.getName(), 1.0f, 0.1f, 1.0f, 0.1f, 1.0f, 0.1f, 1.0f);
scaleX.setDuration(durationSX);
animatorList.add(scaleX);
// 透明化"alpha"
alpha = ObjectAnimator.ofFloat(view, View.ALPHA.getName(), 1.0f, 0.0f);
alpha.setDuration(Math.max(Math.max(durationTY, durationTX), durationSX) / 2);
animatorList.add(alpha);
// アニメーションの複合化、再生
AnimatorSet set = new AnimatorSet();
set.playTogether(animatorList);
set.start();
}
}
このMainActivity
のためのレイアウトリソースファイルです。
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<!-- 契約書だよ。そこに名前を書きな。 -->
<TextView
android:id="@+id/keiyakushodayo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:fontFamily="@font/sanarifont"
android:gravity="center_horizontal"
android:text="@string/keiyakushodayo"
android:textColor="@android:color/black"
android:textSize="30sp" />
<!-- 名前入力欄 -->
<EditText
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/keiyakushodayo"
android:fontFamily="@font/sanarifont"
android:textSize="40sp" />
<!-- 書き終わったらクリックしてください -->
<Button
android:id="@+id/keiyaku_button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/name"
android:fontFamily="@font/sanarifont"
android:text="@string/keiyakusuru"
android:textSize="30sp" />
<!-- 「フン~」セリフ -->
<TextView
android:id="@+id/hun"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/keiyaku_button"
android:fontFamily="@font/sanarifont"
android:gravity="center_horizontal"
android:text="@string/hun"
android:textColor="@android:color/black"
android:textSize="30sp"
android:visibility="invisible" />
<!-- ここに入力した名前のTextViewを埋め込みます -->
<LinearLayout
android:id="@+id/name_area"
android:layout_width="match_parent"
android:layout_height="200dp"
android:layout_centerVertical="true"
android:gravity="bottom|center_horizontal"
android:orientation="horizontal" />
<!-- 「いいかい~」セリフ -->
<TextView
android:id="@+id/iikai"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/name_area"
android:fontFamily="@font/sanarifont"
android:gravity="center_horizontal"
android:text="@string/iikai"
android:textColor="@android:color/black"
android:textSize="30sp"
android:visibility="invisible" />
</RelativeLayout>
変数名やid名が英語やローマ字が混在していますが、そこらへんの命名規約の破綻はご容赦ください。
レイアウトリソースファイルはもう1枚あります。
MainActivity
内でLayoutInflater
にTextView
オブジェクトにしてもらうためのタネです。
このTextView
は、プログラムの方で1字ずつ分解した「野」や「千」を表示するためのものです。
プログラムの方でふわーっと浮いてどこかへ行くアニメーションを設定します。
<?xml version="1.0" encoding="utf-8"?>
<!-- 名前1字分 -->
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/sanarifont"
android:foregroundGravity="bottom"
android:textColor="@android:color/black"
android:textSize="80sp" />
最後に文字列リソースファイルです。
<resources>
<string name="app_name">湯婆婆</string>
<string name="keiyakushodayo">契約書だよ。\nそこに名前を書きな。</string>
<string name="keiyakusuru">契約する</string>
<string name="hun">フン。\n%sというのかい。\n贅沢な名だねぇ。</string>
<string name="iikai">今からお前の名前は%sだ。\nいいかい、%sだよ。\n分かったら返事をするんだ、%s!!</string>
</resources>
%s
の箇所が、新しい名前を埋め込む箇所です。
ポイント
そもそも元ネタ「Javaで湯婆婆を実装してみる」がJavaで実装し、私のこのAndroidアプリもJavaで実装しているわけですから、入出力のUIは違えども、アルゴリズム的には同じすぎて、この私の記事の存在意義が疑われるところではあります。
ですのでこの記事はむしろAndroidアプリ開発におけるTipsという位置づけでお読みいただければと存じます。
そこで、
- カスタムフォント
-
ObjectAnimator
とAnimatorSet
を使ったアニメーション - ソフトウェアキーボードの引っ込ませ方
- AndroidのJavaはJavaではない不思議な世界
について以下説明します。
フォントにこだわってみました
今一度、前掲の紙に書いた名前がふわーっと浮いてどこかへ行ってしまうシーンを鑑賞していただきたいのですが、なんとも味のある字体です。
そこで似たようなフリーフォントを探しました。
それが細鳴りフォント(Sanarifont)です。ありがとうございます。
APIレベル26から、フォントリソースが導入されました。これが導入される前は難儀を強いられましたが、導入後はラクになったものです。
フォントファイル(.ttf、.ttc、.otf、.xml)を/res/fontフォルダに格納します。ファイル名はリソースIDとして使用されます。
使用箇所は、
Typeface typeface = getResources().getFont(R.font.sanarifont);
や、
<TextView
android:fontFamily="@font/sanarifont" />
といったところです。
字がふわーっと浮いてどこかへ行ってしまうアニメーション
「荻」や「千」の字を埋め込んだTextView
を移動させ透明化させます。
というわけで、参考にしたのがPieceX:AndroidStudioでアニメーション アニメーションで紙吹雪を降らせる方法を考えてみましたです。ありがとうございます。
MainActivity
に定義したprivate
メソッドのコードは、このサイトを参考にさせていただきました。
ポイントは、様々な挙動をするアニメーションを複合化して同時に再生することです。今回は、
- 上昇
- 左右にゆらゆら
- 透明化
といった挙動をまとめてアニメーションしたかったわけです。
そこでandroid.animation.ObjectAnimator
を利用するのが適しているようです。
それぞれのObjectAnimator
オブジェクトに、プロパティ("translationY"
や"scaleX"
など)と値(1.0f
や0.1f
など)を設定し、List
にまとめてandroid.animation.AnimatorSet
に俺とプレイトゥギャザーしてスタートしようぜ!(ルー大柴の声でお楽しみください)で済みます。
普段あまりアニメーションなんかやったことがない私は、ここにいちばん時間をかけてしまいました。でも楽しかったです。
このアニメーション、もっともっと凝ることもできるかと思いますし、そういうライブラリーも世に有るかとは思いますが、私は一つ自分への枷として標準APIのみで実装することに挑戦してみたかった3ので、この程度のアニメーションで「もういいか」とさせてください。
EditTextをクリックしたら出てきちゃうソフトウェアキーボードを引っ込めさせる
「契約する」ボタンをクリックしたら、見せ所はその下方のアニメーションと湯婆婆のキメ台詞なのに、依然としてソフトウェアキーボードがのさばって見えねーじゃねーか!ということで、
InputMethodManager inputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
inputMethodManager.hideSoftInputFromWindow(editText.getWindowToken(), 0);
editText.clearFocus();
InputMethodManager
さんにhideしてくれるように頼みました。これがホントの神隠し。なんちって。
Stringを1字ずつの配列にしたくて、String#splitしてみたら...
なにげにこんなことで躓きました。
当初はこんなコードで済ませていたのです。
String[] names = name.split("");
ところがこれだと配列の0番目に空文字(長さ0の文字列)が入ってしまっていました。4
調べてみると、String
のsplit
メソッドはJDK1.7まではそのような挙動をし、JDK1.8以降ならば本来の私のやりたかった挙動をしてくれるようです。
compileOptions {
sourceCompatibility = 1.8
targetCompatibility = 1.8
}
とJDK1.8でお願いしますと設定してある5のにー?!...こういったあたりで「AndroidのJavaは、Javaに非ず」というダークな面が垣間見えますね。
ということで、なんかスマートじゃないなぁと後ろめたさもありながらも、String#charAt
をグルグル回してやりました。
Try KotlinでString#splitしてみたら...
ちなみに、Kotlinに書き換えてみても、同じでした(配列の0番目が空文字だった)。
「AndroidのKotlinだからかな?」と思って、Try Kotlinにて以下のKotlinプログラムを実行してみました。
fun main(args : Array<String>) {
println("Hello, Kobaba!")
val name = "荻野千尋"
val names = name.split("")
for (aStr in names) {
println(aStr)
}
println(names.size)
}
Hello, Kobaba!
荻
野
千
尋
6
え。うそ。6個?!
このTry Kotlinで試せるすべてのバージョンで実行しても、同じ結果に。
EclipseでString#splitしてみたら...
急いでEclipse(Version: 2020-06)を起動して、以下のJavaを書きました。
public static void main(String[] args) {
System.out.println("Hello, jababa!");
String name = "荻野千尋";
String[] names = name.split("");
for (String aStr : names) {
System.out.println(aStr);
}
System.out.println(names.length);
}
Hello, jababa!
荻
野
千
尋
4
まあ、私のEclipseがJDK11だからなんでしょうけど、もう何が何やら。不思議な世界に迷い込んでしまったような気分、もう私は空を見上げてこう口遊むしかないです。
繰り返すあやまちの そのたび ひとは ただ青い空の 青さを知る
クラッシュ湯婆婆
そういえば、無記入の場合はちゃんとクラッシュ湯婆婆すべきなのがこのネタの重要な仕様でしたね!
安心してください、ちゃんとクラッシュ湯婆婆します。
セリフ「java.lang.IllegalArgumentException: bound must be positive」まで一緒です。そうこなくっちゃ。
実現できなくて心残りなこと
- フォントサイズが決め打ちのままです。5字以上の名前(「ダルビッシュ有」さんとか)を考慮していないままです。端末によってはとにかくはみ出ます。ちゃんと丁寧に
<dimen>
で定義すべきなのでしょう。 - ふわーっと浮いてどこかへ行ってしまう字の影も再現したかったです。
- 紙から離脱当初は浮遊されるのを抗って粘って踏みとどまろうとするのだが、いざ離脱してしまったら抵抗を諦めたという加速感。(もっといっぱい
ObjectAnimator
を作って組み合わせればいいのかな。あと、android.view.animation.AccelerateInterpolator
とか使えばいいのかな) - 映画のシーンをなぞって縦書き。(Androidで縦書きを実現することがどんだけ難儀なことか!)
- サロゲートペア対策
サロゲートペア対策ねぇ...ちょ、ちょっと待って。
サロゲートペア対策「𠮷田さん」よりも「获野千尋」!
まずこの湯婆婆ネタで賑わっているサロゲートペア漢字「𠮷」の対策を講じようかと思い、でも今回使用させていただいたフォントでは「𠮷」が含まれていないのではないかと危惧したので、文字情報技術促進協議会が提供しているIPAmj明朝フォントで試してみようと...したのですが。
そんなことよりも!
しつこいのですが、前掲の紙に書いた名前がふわーっと浮いてどこかへ行ってしまうシーンを鑑賞してください。または、ビデオ持っている方はそのシーンを眼ん玉ひん剥いて視聴してみてください。
■千と千尋お得情報メモ 荻野千尋…と書いてあると思いきや、荻の字が間違っています!本来“火”と書くべきところが“犬”になっていますね。千尋さんは書き間違えただけなのか、わざと間違えたのか…気になるところですぅーー😳 #千と千尋の神隠し #千 #せんちひ pic.twitter.com/64i5tN9SAQ
— アンク@金曜ロードSHOW!公式 (@kinro_ntv) January 20, 2017
「荻」の字が「获」となっているのです!くさかんむり「艹」+けものへん「犭」+「火」ではなく、「犬」という構成になっているのです!「萩」と間違えている場合じゃない!
ナニその漢字?!製作ミス?!監督の遊び心?!ネットでも諸説紛紛ですぅーー
しかも調べてみたら、「获」という漢字は実在していて、なんと「獲」の簡体字(中国で使われている簡略化した字体の漢字)だそうです!UnicodeはU+83B7。
千尋ちゃんは「オギノ」さんじゃなく「カクノ」さんなの?!
んでもって門田さんが面倒臭がって「门」とか書いちゃうノリ?!
サロゲートペア漢字もサポートしつつ、中国の簡体字もサポートしたフォント...そんなのあるのかしら?と探そうと思いましたが、とっくにありました。
私の持っているAndroidスマホの中に。標準フォントとして。
最後に
なんやかんやありましたが、結果一番お気に入りなのは、ランチャーアイコンです。
スタジオジブリは作品の場面写真を常識の範囲で提供してくださっています。これもありがたいことですね。
以上です。良いお年を。
追記
あれからもうちょっと頑張ってみました。影なども再現してみたりしました。
-
この記事投稿時点での最新バージョンのAndroid Studioには、Javaで実装したプロジェクトを一気にKotlinに変換してくれる機能があります。でもその逆はありません。ですので、Javaで書いたものはいつでもKotlinに変換できますし。 ↩
-
このアニメーションGIFはダウンロード可能のようなのですが、他サイト(つまりこのQiita)に貼り付けていいのかわからなかったのでリンクにしました。 ↩
-
そう言うわりには、Jetpackの
AppCompatActivity
を使っているというのはどういう了見なんだい?...湯婆婆様、そこだけは見逃しておくんなまし。 ↩ -
そのおかげで何度か試行してたら「荻野千尋」の4字すべて消えてしまう事態が発生。
Random
がチョイスしたのが0だったんでしょう。「今からお前の名前はだ。いいかい、だよ。分かったら返事をするんだ、!!」って言われちゃいました。
え~~~。build.gradleにちゃんと、 ↩ -
おかげでラムダ式は書けました。 ↩