19
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

湯婆婆Androidアプリを作ってみました

Last updated at Posted at 2020-12-03

メリークリスマス。この記事は、湯婆婆 Advent Calendar 2020の4日目です。

賛辞と謝辞

まずは@Nemesisさんに賛辞と謝辞を送ります。
Javaで湯婆婆を実装してみるという記事で、このようなムーヴメントを興こせるだなんて、素晴らしいです。
呼んでいる胸のどこか奥でいつも心踊る夢を見たい気分にさせていただきました。

私にこのような記事を投稿させてもらえる機会を与えていただき、ありがとうございます。

先日、Kotlinで湯婆婆を実装という記事を投稿させていただきました。
でも私の中では心のどこかでAndroidアプリにしたい、という想いが残っていて。
ということで作ったみた次第です。しかし今回はKotlinではなく、Javaで1

やりたいこと

まずは、こちらの名シーンをご覧ください。

GIFMAGAZINE:紙に書いた名前がふわーっと浮いてどこかへ行ってしまうGIF画像 2

荻野千尋おぎのちひろが書いた字が、ふわーっと浮いてどこかへ行ってしまうシーンです。これを実現したいです。

完成したアプリ

OLD_sentochihiro.gif

名前を入力してボタンをクリックすると、1字を残してふわーっと浮いてどこかへ行ってしまうアプリです。

なお、このアニメーションGIFを作るにあたって、うっかりおぎ野千尋を、誤って「はぎ」にしてしまっていました。これはオギノさんハギノさんあるあるなのかもしれませんが、実はこれが後々に恐ろしいことにつながっていきます...詳しくは後述をご覧ください(勿体もったいぶってすみません)。

コード

とりあえずまずは、ずらずらずら~っと掲載しておきます。
可能な限りコメントを付けましたので、日本語と照らし合わしながら解読していただければ幸いです。
ポイント解説は後ほど。

画面を実現するActivityクラスです。というか、Javaのプログラムはこれ1個だけのアプリです。

MainActivity.java
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のためのレイアウトリソースファイルです。

activity_main.xml
<?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内でLayoutInflaterTextViewオブジェクトにしてもらうためのタネです。
このTextViewは、プログラムの方で1字ずつ分解した「野」や「千」を表示するためのものです。
プログラムの方でふわーっと浮いてどこかへ行くアニメーションを設定します。

name_entity.xml
<?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" />

最後に文字列リソースファイルです。

strings.xml
<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という位置づけでお読みいただければと存じます。
そこで、

  • カスタムフォント
  • ObjectAnimatorAnimatorSetを使ったアニメーション
  • ソフトウェアキーボードの引っ込ませ方
  • AndroidのJavaはJavaではない不思議な世界

について以下説明します。

フォントにこだわってみました

今一度、前掲の紙に書いた名前がふわーっと浮いてどこかへ行ってしまうシーンを鑑賞していただきたいのですが、なんとも味のある字体です。
そこで似たようなフリーフォントを探しました。
それが細鳴りフォント(Sanarifont)です。ありがとうございます。

APIレベル26から、フォントリソースが導入されました。これが導入される前は難儀を強いられましたが、導入後はラクになったものです。

フォントファイル(.ttf、.ttc、.otf、.xml)を/res/fontフォルダに格納します。ファイル名はリソースIDとして使用されます。

使用箇所は、

プログラム上なら
Typeface typeface = getResources().getFont(R.font.sanarifont);

や、

レイアウトXML上なら
<TextView
    android:fontFamily="@font/sanarifont" />

といったところです。

字がふわーっと浮いてどこかへ行ってしまうアニメーション

「荻」や「千」の字を埋め込んだTextViewを移動させ透明化させます。

というわけで、参考にしたのがPieceX:AndroidStudioでアニメーション アニメーションで紙吹雪を降らせる方法を考えてみましたです。ありがとうございます。
MainActivityに定義したprivateメソッドのコードは、このサイトを参考にさせていただきました。

ポイントは、様々な挙動をするアニメーションを複合化して同時に再生することです。今回は、

  • 上昇
  • 左右にゆらゆら
  • 透明化

といった挙動をまとめてアニメーションしたかったわけです。

そこでandroid.animation.ObjectAnimatorを利用するのが適しているようです。
それぞれのObjectAnimatorオブジェクトに、プロパティ("translationY""scaleX"など)と値(1.0f0.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#splitでいいや
String[] names = name.split("");

ところがこれだと配列の0番目に空文字(長さ0の文字列)が入ってしまっていました。4
調べてみると、StringsplitメソッドはJDK1.7まではそのような挙動をし、JDK1.8以降ならば本来の私のやりたかった挙動をしてくれるようです。

build.gradle
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プログラムを実行してみました。

KotlinでString#splitしてみた
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を書きました。

EclipseでString#splitしてみたくて
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だからなんでしょうけど、もう何が何やら。不思議な世界に迷い込んでしまったような気分、もう私は空を見上げてこう口遊くちずさむしかないです。

繰り返すあやまちの そのたび ひとは ただ青い空の 青さを知る

クラッシュ湯婆婆

そういえば、無記入の場合はちゃんとクラッシュ湯婆婆すべきなのがこのネタの重要な仕様でしたね!

安心してください、ちゃんとクラッシュ湯婆婆します。

crashyubaba.png

セリフ「java.lang.IllegalArgumentException: bound must be positive」まで一緒です。そうこなくっちゃ。

実現できなくて心残りなこと

  • フォントサイズが決め打ちのままです。5字以上の名前(「ダルビッシュ有」さんとか)を考慮していないままです。端末によってはとにかくはみ出ます。ちゃんと丁寧に<dimen>で定義すべきなのでしょう。
  • ふわーっと浮いてどこかへ行ってしまう字のも再現したかったです。
  • 紙から離脱当初は浮遊されるのをあらがって粘って踏みとどまろうとするのだが、いざ離脱してしまったら抵抗を諦めたという加速感。(もっといっぱいObjectAnimatorを作って組み合わせればいいのかな。あと、android.view.animation.AccelerateInterpolatorとか使えばいいのかな)
  • 映画のシーンをなぞって縦書き。(Androidで縦書きを実現することがどんだけ難儀なことか!)
  • サロゲートペア対策

サロゲートペア対策ねぇ...ちょ、ちょっと待って。

サロゲートペア対策「𠮷田さん」よりも「获野千尋」!

まずこの湯婆婆ネタで賑わっているサロゲートペア漢字「𠮷」の対策を講じようかと思い、でも今回使用させていただいたフォントでは「𠮷」が含まれていないのではないかと危惧したので、文字情報技術促進協議会が提供しているIPAmj明朝フォントで試してみようと...したのですが。

そんなことよりも!

しつこいのですが、前掲の紙に書いた名前がふわーっと浮いてどこかへ行ってしまうシーンを鑑賞してください。または、ビデオ持っている方はそのシーンを眼ん玉ひん剥いて視聴してみてください。

おぎ」の字が「」となっているのです!くさかんむり「艹」+けものへん「犭」+「」ではなく、「」という構成になっているのです!「はぎ」と間違えている場合じゃない!

ナニその漢字?!製作ミス?!監督の遊び心?!ネットでも諸説紛紛ですぅーー:flushed:

しかも調べてみたら、「获」という漢字は実在していて、なんと「獲」の簡体字(中国で使われている簡略化した字体の漢字)だそうです!UnicodeはU+83B7。

千尋ちゃんは「オギノ」さんじゃなく「カクノ」さんなの?!
んでもって門田さんが面倒臭がって「门」とか書いちゃうノリ?!

サロゲートペア漢字もサポートしつつ、中国の簡体字もサポートしたフォント...そんなのあるのかしら?と探そうと思いましたが、とっくにありました。
私の持っているAndroidスマホの中に。標準フォントとして。

最後に

なんやかんやありましたが、結果一番お気に入りなのは、ランチャーアイコンです。

ic_launcher.png

スタジオジブリは作品の場面写真を常識の範囲で提供してくださっています。これもありがたいことですね。

以上です。良いお年を。

追記

あれからもうちょっと頑張ってみました。影なども再現してみたりしました。

yubaba_app.gif

  1. この記事投稿時点での最新バージョンのAndroid Studioには、Javaで実装したプロジェクトを一気にKotlinに変換してくれる機能があります。でもその逆はありません。ですので、Javaで書いたものはいつでもKotlinに変換できますし。

  2. このアニメーションGIFはダウンロード可能のようなのですが、他サイト(つまりこのQiita)に貼り付けていいのかわからなかったのでリンクにしました。

  3. そう言うわりには、JetpackのAppCompatActivityを使っているというのはどういう了見なんだい?...湯婆婆様、そこだけは見逃しておくんなまし。

  4. そのおかげで何度か試行してたら「荻野千尋」の4字すべて消えてしまう事態が発生。Randomがチョイスしたのが0だったんでしょう。「今からお前の名前はだ。いいかい、だよ。分かったら返事をするんだ、!!」って言われちゃいました。
    え~~~。build.gradleにちゃんと、

  5. おかげでラムダ式は書けました。

19
9
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?