言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

Activity Shared Element TransitionでActivityが多重起動されるとAnimationがおかしくなる

はじめに

Activity Shared Element TransitionでActivityが多重起動されるとAnimationがおかしくなる現象を見つけて面白かったので調べてみた。

具体的に

まずは下記のgifを見てほしい。リストアイテム押下時にわざと1秒のdelayを入れてstartActivity()している。 この時、Activity Shared Element Transitionするように bundlestartActivity()の第2引数に渡している。

f:id:muumuumuumuu:20190602161732g:plain

gifでやっていること手順をまとめると、

  1. 左上のアイテムをタップ、1秒のdelayが走る(A)
  2. 1のdelayが終わる前に右上のアイテムをタップ(B)
  3. Aのdelayが終わり、左上のタップイベントが発火、startActivity()が走る
  4. Bのdelayが終わり、右上のタップイベントが発火、startActivity()が走る
  5. Aのタップイベント契機のActivityが起動される
  6. Bのタップイベント契機のActivityが起動される
  7. Back keyをタップしてBのactivityがfinishされる
  8. Back keyをタップしてAのactivityがfinishされる

step 8のあと、A, B両方のshared itemはなぜかBのpositionに戻っていき、Aのitemがいなくなってしまうのである。

結論だけ言うと

一つのActivityに登録できるActivity Shared Element Transition用のbundleは一つだけで、しかも上書き。このようなケースを防ぎたかったらActivityの多重起動はできないようにするしかない。

何が起こったのか

何が起こったかを理解するためには、Activity Shared Element Transitionの仕組みを知らないといけない。そのためにFrameworkの内部実装を読んでいく。

このシーケンス図を見てもらえればだいたいわかるんだけど、 f:id:muumuumuumuu:20190602175004p:plain

まずActivity起動時にstartActivity()の第2引数として渡すbundle (ActivityOptions) は色々あって最終的にActivityStarterActivityRecordを作るときに使われる。
別のactivityから戻って来たときのアニメーション用にこのoptionを取得する必要があるが、このときActivityRecordのstatic methodであるisInStackLocked()が使われる。

    static ActivityRecord isInStackLocked(IBinder token) {
        final ActivityRecord r = ActivityRecord.forTokenLocked(token);
        return (r != null) ? r.getStack().isInStackLocked(r) : null;
    }

このmethodはactivityのtokenからActivityRecordを返してくれる。そしてActivityRecord内に格納されているActivityOptionsを使ってActivityTransitionState#setEnterActivityOptions()がコールされる。

と言うわけでActivityOptionsとactivityのtokenは一対一で対応される。Activityが多重で起動されると起動された順番とfinishされる順番がおかしくなるとアニメーションもおかしくなる。この仕組みはアプリからは避けられないので、アプリとしてはActivityの多重起動を避けると言うアプローチがいいと思う。

おまけ

今回貼ったシーケンス図は下記のサイトで作りました。めっちゃ便利。 シーケンス図って最近エンジニアになった人には(特にweb系)馴染みのない古のツールかもしれないけど、私は好きです。

sequencediagram.org

title How to handle enter transition
note over User, Activity: Search Result Screen
User->User:click item
User->Activity:startActivityForResult()
Activity->Instrumentation:execStartActivity()
Instrumentation->ActivityManagerService:startActivity()
ActivityManagerService->ActivityManagerService:startActivityMayWait()
ActivityManagerService->ActivityStarter:setActivityOptions()

note over ActivityStarter:mRequest.activityOptions = options;
ActivityManagerService->ActivityStarter:execute()
ActivityStarter->ActivityStarter:startActivityMayWait()
ActivityStarter->ActivityStarter:startActivity()

note over ActivityStarter: create ActivityRecord using activity option as a one of parameter


note over User,ActivityStarter: New Activity is displayed

User->User:click back key
User->Activity:performStart()
Activity->Activity:getActivityOptions()
Activity->ActivityManagerService:getActivityOptions(mToken)
note over Activity,ActivityManagerService: get acvitivy options from ActivityRecord from static method
Activity<--ActivityManagerService:options
Activity->ActivityTransitionState:setEnterActivityOptions()

Javaのmethod referenceは初回しか評価されない

はじめに

言いたいことは掲題の通り。method referenceを渡しちゃうと実行時に毎度評価されるわけではない。 毎度評価されたければlambdaを渡した方がいい。

Reference

公式のドキュメントには以下のように記述がある。

Chapter 15. Expressions

The timing of method reference expression evaluation is more complex than that of lambda expressions (§15.27.4). When a method reference expression has an expression (rather than a type) preceding the :: separator, that subexpression is evaluated immediately. The result of evaluation is stored until the method of the corresponding functional interface type is invoked; at that point, the result is used as the target reference for the invocation. This means the expression preceding the :: separator is evaluated only when the program encounters the method reference expression, and is not re-evaluated on subsequent invocations on the functional interface type.

上記の様に最後の一文に「merhod referenceのプログラムに出会った時だけ評価されて、呼び出しのタイミングでは再評価されない」と明言されている。

具体的に

どう言う時に困るかと言うと、例えばクラス生成時に何かをsubscribeしていてviewを更新したいんだけど、viewだけが作り変えられてしまう時とか困る。viewが作り変えられるのでviewのinstanceは新しくなってるんだけど、method referenceでviewを更新しようとしてもこちらは再評価されず古いinstanceを更新しようとしてUIは何も変わらないということが起こりうる。

実際にコード例を示す。

例えばFragmentでBetterKnifeを使っていて、onCreateView() でviewをbindし、

@BindView(R.id.button)
Button button;

@BindView(R.id.image)
MyImageView imageView;

@Nullable
@Override
public View onCreateView(
        @NonNull LayoutInflater inflater,
        @Nullable ViewGroup container,
        @Nullable Bundle savedInstanceState
) {
    View view = inflater.inflate(R.layout.fragment_main, container, false);
    ButterKnife.bind(this, view);
    return view;
}

onViewCreated()本当に初回だけ以下のようなsubscribeを開始するとする。 以下は何かbuttonがあって、押されるたびに別のimage viewのvisibilityがVISIBLE <-> GONEでtoggleになる処理だ。 (何かしらの事情があってviewが作り変えられるたびにsubscribeしたくないものと仮定して読んでほしい :pray: )

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    if (!isInitialized) {
        disposable.add(
                RxView.clicks(button)
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribe(imageView::toggleVisibility)
        );
    }
    isInitialized = true;
}

一見良さそうに見えるがfragmentが一度detachされ、再度attachされた後、buttonを押してもimage viewのvisibilityは変わらない。この時点でsubscribe()の引数に渡されたmethod referenceは古いview imageのinstanceに対する参照を持っているからだ。

@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
    super.onViewCreated(view, savedInstanceState);
    if (!isInitialized) {
        disposable.add(
                RxView.clicks(button)
                        .observeOn(AndroidSchedulers.mainThread())
                .subscribe((signal) -> {
                    imageView.toggleVisibility(signal);
                })
        );
    }
    isInitialized = true;
}

こんな感じでlambdaを渡してやることで回避できる。

AppBarLayoutを使う時にlayout_behaviorをFragmentに持たせるとviewが重ねて表示される

困った問題に出会ったのでメモ。

何が起こったか

Activityが CoordinatorLayout , その中に AppBarLayout を持っていて、(諸々省略するとこんな感じ)

<androidx.coordinatorlayout.widget.CoordinatorLayout>

    <com.google.android.material.appbar.AppBarLayout>

        <TextView
            app:layout_scrollFlags="scroll|enterAlways" />

    </com.google.android.material.appbar.AppBarLayout>

    <RelativeLayout /> <!-- Fragmentをhostingするcontainerのview group -->
</androidx.coordinatorlayout.widget.CoordinatorLayout>

上記のcontainerに app:layout_behavior をセットしたRecyclerViewを持ったFragmentをaddします。

<androidx.constraintlayout.widget.ConstraintLayout>
    <androidx.swiperefreshlayout.widget.SwipeRefreshLayout
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.recyclerview.widget.RecyclerView />

    </androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

するとこんな感じでRecyclerViewとAppBarLayoutが重ねて表示されてしまう。

f:id:muumuumuumuu:20190224180029p:plain:w200

Fragmentにわけない、もしくはActivityのcontainerに app:layout_behavior を設定する場合は問題なく表示されます。

f:id:muumuumuumuu:20190224180320p:plain:w200

なんでこんなことになるのか?

せっかくなのでAppBarLayoutCoordinatorLayoutの中身を見ていく。

そもそも前提として、app:layout_behaviorをつけると何が起きるのか?
CoordinatorLayout.LayoutParamsのコンストラクタでattribute setからapp:layout_behaviorで取得したbehaviorを取得しメンバに保持している。

android.googlesource.com

    public static class LayoutParams extends MarginLayoutParams {

        LayoutParams(@NonNull Context context, @Nullable AttributeSet attrs) {

                    R.styleable.CoordinatorLayout_Layout_layout_behavior);
            if (mBehaviorResolved) {
                mBehavior = parseBehavior(context, attrs, a.getString(
                        R.styleable.CoordinatorLayout_Layout_layout_behavior));
            }

app:layout_behaviorに指定するのは@string/appbar_scrolling_view_behaviorを使うように公式referenceにも書いているが、この参照値の実態はcom.google.android.material.appbar.AppBarLayout$ScrollingViewBehaviorというクラスパスである。上記のparseBehavior()でreflectionを使ってBehaviorのインスタンスを生成し、メンバのmBehaviorに格納する。

CoordinatorLayoutのlayoutがonPreDraw()で走る時に、onChildViewsChanged()が呼ばれるが、このメソッドの中で先ほど格納したmBehaviorをgetしてonDependentViewChanged()をコールする。

    final void onChildViewsChanged(@DispatchChangeEvent final int type) {

            // Update any behavior-dependent views for the change
            for (int j = i + 1; j < childCount; j++) {
                final View checkChild = mDependencySortedChildren.get(j);
                final LayoutParams checkLp = (LayoutParams) checkChild.getLayoutParams();
                final Behavior b = checkLp.getBehavior();

                    switch (type) {
                        case EVENT_VIEW_REMOVED:

                            break;
                        default: // typeはEVENT_PRE_DRAWなのでdefaultに入る
                            // Otherwise we dispatch onDependentViewChanged()
                            handled = b.onDependentViewChanged(this, checkChild, child);
                            break;
                    }

onDependentViewChanged() が呼ばれた後はいくつかのメソッドを経由してAppBarLayout.ScrollingViewBehavior#offsetChildAsNeeded()に辿り着く。この中でdependencyであるAppBarLayoutのbottom位置を取得して重ならないように良い感じに調整してくれる。

github.com

  public static class ScrollingViewBehavior extends HeaderScrollingViewBehavior {

    private void offsetChildAsNeeded(View child, View dependency) {
      final CoordinatorLayout.Behavior behavior =
          ((CoordinatorLayout.LayoutParams) dependency.getLayoutParams()).getBehavior();
      if (behavior instanceof BaseBehavior) {
        // Offset the child, pinning it to the bottom the header-dependency, maintaining
        // any vertical gap and overlap
        final BaseBehavior ablBehavior = (BaseBehavior) behavior;
        ViewCompat.offsetTopAndBottom(
            child,
            (dependency.getBottom() - child.getTop())
                + ablBehavior.offsetDelta
                + getVerticalLayoutGap()
                - getOverlapPixelsForOffset(dependency));
      }
    }

というわけで一連の処理の起点にはmBehaviorが必要だ。しかし、最初に見た通りmBehaviorCoordinatorLayout.LayoutParamsのコンストラクタで取得される。fragmentで独立したxmlを持ってそこからinflateしたfragmentをコードから動的にattachする場合、fragmentのLayoutParamsはCoordinatorLayout.LayoutParamsにならないので1mBehaviorが取得できない。 そのためapp:layout_behaviorを記述したにも関わらずoffsetが正しく設定されずviewが重なって表示されていた。

で、どうすれば良い?

CoordinatorLayout.LayoutParamsからbehaviorが取得できれば良いので、CoordinatorLayoutのchildにapp:layout_behaviorをつけるしかないと思う。以下のようなStack Overflowの記事を見つけたけれどそもそもLayoutParamsがCoordinatorLayout.LayoutParamsにcastできないのでハイハイ解散という感じだ。

stackoverflow.com

そもそもなんでこんなことやりたかったかというと、containerにattachするfragmentによってAppBarLayoutがscrollしたりしなかったりという挙動を実装したくて、fragmentの中にapp:layout_behaviorが書けるのであればそっちでscroll制御できるので楽そうという理由だった。

楽はできなさそうなのでattachするfragmentによってscrollFragを変更してやるしかなさそう。

// fragmentをattach/replaceするタイミング
val text = findViewById<TextView>(R.id.text) // `app:layout_scrollFlags` を設定していたview
val layoutParam = text.layoutParams as? AppBarLayout.LayoutParams ?: return
val scrollFrag = layoutParam.scrollFlags
if ((scrollFrag and AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL) == 1) {
    // もしもscrollのオプションが付いていたらとる(scrollしないようにする)
    layoutParam.scrollFlags = scrollFrag - AppBarLayout.LayoutParams.SCROLL_FLAG_SCROLL
}

  1. 最初に示したxmlの例の場合はViewGroup#LayoutParamsになる。ここを見ると、LayoutParamsはroot viewのgetNameに$LayoutParamsをつけてreflectionしたclazzからinstance生成したもののようだ

Kotlin Bytecodeを読みたいけど、何から勉強したらいいかわからないあなたへ。ASMのドキュメントを読みましょう。

はじめに

このblog postは半年以上下書き放置していて自分の中では旬がすぎた話題ではありますが、最近Android系日本語技術podcastであるdex.fmのep.66でbytecodeが話題になったので、勿体無い精神で公開します :pray: それではどうぞ。

Kotlin書いているとたまに「これ、JavaのBytecodeだとどう変換されて動いているんだろう?」と思う時がしばしばある。

例えばこれとか

muumuutech.hatenablog.com

で、Android Studioの "Show Kotlin Bytecode" 機能を使ってbytecodeをのぞいてみるんだけど、完全に雰囲気で読んでいたので、わかるようなわからないようなゆるふわな感じだった😇これの意味は?とか聞かれても説明するなんて無理〜〜と言う程度の理解レベル。

何がつらいかっていうと、ググってもいい感じのリファレンスがないというか、まず何を読むべきかが見つけられないという状況😩😩😩 しかし軽い気持ちで呟いた結果、神々が降臨した。圧倒的感謝 :pray: 1

というわけで、Bytecodeに対してゆるふわな理解しかない人が勉強して行った記録を残していく。間違ったこと書いてたら教えてくださいmm

手取り早くbytecodeを理解した人へ

ASM User GuideのAppendixにBytecode instructionsがまとまっているので、ここを読みましょう。

ASMとは?

まずは公式の定義から。

ASM

ASM is an all purpose Java bytecode manipulation and analysis framework. It can be used to modify existing classes or to dynamically generate classes, directly in binary form. ASM provides some common bytecode transformations and analysis algorithms from which custom complex transformations and code analysis tools can be built. ASM offers similar functionality as other Java bytecode frameworks, but is focused on performance. Because it was designed and implemented to be as small and as fast as possible, it is well suited for use in dynamic systems (but can of course be used in a static way too, e.g. in compilers).

KotlinのコンバイラはASMライブラリを使ってbytecodeを生成している。なのでKotlinで生成されたBytecodeを読みたいだけだったら、ASMが何をしてどういう生成物を吐き出すか知っていれば良いっぽい。

余談だけどASMはOSSで、しかもGithubではなくGitlabでhostingされている。

gitlab.ow2.org

User Guide

まずは使う側としてUser Guide読むことにする。めちゃめちゃ長いPDFになっている。

https://asm.ow2.io/asm4-guide.pdf

読んでて面白かった部分を書いていく。なんかたまにルー大柴っぽくなった気がする。気になる人は原文読んでください…

1. Introduction

  • ASMはできるだけ早くて小さくて堅牢というのを目指している
  • ASMライブラリの目的はbyte arrayで表現されるコンパイルずみのJava classesを生成、変換、分析すること
    • class loading processは対象外
  • ASMという名前に意味はない。Cの__asm__ keyword からとっただけ
  • core APIはevent basedなクラス表現
  • tree APIはobject basedなクラス表現
    • この辺 見ると、Kotlin compilerはtreeの方使ってるのかな?という感じがする

Tree API

2. Classes 3. Methods 4. Metadata 5. Backward compatibility の章はCore APIについてだったのでスキップ)

6. Classes

  • tree APIを使ってclassを生成、変換する場合、core APIに比べて約3割多くの時間がかかり、メモリも多く使う
  • そのかわりどんな順序でもclass elementsが生成できるので便利

7. Method

  • methodの中身はInsnListでinstructionが記述されていく
    • こいつがgetOpCode()とかを持っている
    • getNext() があるからjumpが簡単にできる
  • Label, frame, line numberもinstructionではないけどAbstractInsnNodeのサブクラスとして表現される
    • これにより実instructionsの前とかにLabelとかをinsertすることができる

8. Method Analysis

  • ASMで使われるcode analysis techniqueにはdata flow analysiscontrol flow analysisがある
  • data flow analysis
    • methodのexecution frameのstateを計算する。
    • forward analysisとbackward analysisがある
    • stackから値をpopして計算して結果をstackにpush
    • interpreterJVMのように見えるが、違いは可能性がある全てのpath, argumentについてsimulateする
      • manipulated valueは可能性がある値の集合になるのでめっちゃ大きくなる。
      • 例)intergerをP="positive or null", N="negative or null, A="all integers" として表すとIADD instructionはoperandがどちらもPだったらPを返し、どちらもNだったらNを返し、そのほかのケースは全てAを返す
  • control flow analysis
    • methodのcontrol data flow graphを計算し、このgraphを解析する
    • graphはbasic blockにdecompileされる。それぞれのbasic blockは(最初のblockを除き)jumpの対象となりうる。
  • Interface and components
    • stackからのpopとかpushはframeworkで、valueをcombineしたり集合の計算とかはInterpreterとかValueと行ったuserが定義したsubclassで行われる
  • Basic Data Flow Analysis
definition means
UNINITIALIZED_VALUE all possible values
INT_VALUE all int, short, byte, boolean or char values
FLOAT_VALUE all float values
LONG_VALUE “all long values”
DOUBLE_VALUE “all double values”
REFERENCE_VALUE “all object and array values”
RETURNADDRESS_VALUE is used for subroutines (see Appendix A.2)
  • Appendix
    • 上記にも書いたが、ここにinstructionsとその操作例がまとまっているのでここを見ればだいたいbytecodeが読めるようになっている

Developer Guide読んでいく

こっちの方が短いけど難しい。3割くらいしか理解できていない気がするのでさらっとメモだけ。 ASM - Developer Guide

Main Algorithms

  • class loader
    • constant poolと(constructorの中の)bootstrap methodをparseする
    • classをparseする
    • class attributeをparseする
    • class attributeに対応したvisit methodをコールする
    • fieldについて上記と同じようなことをする
    • methodについて同上
      • ただしattributeはparseした後local variableにstore
      • labelを探してstore
      • instructionsをparse
  • 3.4.3 An example
    • 要約するのがむずいのでこの実例を見てくれたらなんとなくわかる





以上、わかるようなわからないような状態から一歩踏み出すためのリファレンス集でした。


  1. こう言う時本当に投げ銭で感謝のお気持ちを表明したいのでみんなKyashとかのリンクをTwitter bioとかに貼っておいてほしい…

TabItemをxml layout fileに記述してもview hierarchyからfindViewById()できない理由

Tweetにリンクを貼っている通り、公式ドキュメントによると

TabItem is a special 'view' which allows you to declare tab items for a TabLayout within a layout. This view is not actually added to TabLayout, it is just a dummy which allows setting of a tab items's text, icon and custom layout.

This view (Layout fileに定義されたTabItem)はただのダミーで、text, icon, それからcustom layoutの設定だけができるとある。

どうやって実装しているのか気になったので、内部実装を調べたのでメモ。

知りたいこと

こんな感じでTabItemをTabLayoutのchild viewとして記述しても、

<android.support.design.widget.TabLayout
    android:id="@+id/tab"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    app:layout_constraintTop_toTopOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent">
    
    <android.support.design.widget.TabItem
        android:id="@+id/tabItem"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</android.support.design.widget.TabLayout>

コードからアクセスしようとしたら(ちなみにIDEはR.idを認識してくれる) f:id:muumuumuumuu:20190211155020p:plain

NPEが発生する。

java.lang.NullPointerException: Attempt to read from field 'android.graphics.drawable.Drawable android.support.design.widget.TabItem.icon' on a null object reference

ViewHierarchyに存在する(?)のにidからviewが引けない仕組みを調べる。

TabLayoutの実装

例によってAndroidXではなくsupport libraryのコードを読んだ結果ですごめんなさい 😇

まず前提としてTabLayoutHorizontalScrollViewをextendsしたviewである。でHorizontalScrollViewViewGroupなので、addView()メソッドをコールされることでchild viewを持つことができる。(LayoutInflatorがxmlを読み込んでview hierarchyを構成するときにparent viewに対してこのaddView()が呼ばれる。)

というわけでTabLayout#addView()を見て行く。

    public void addView(View child) {
        this.addViewInternal(child);
    }

    public void addView(View child, int index) {
        this.addViewInternal(child);
    }

    public void addView(View child, android.view.ViewGroup.LayoutParams params) {
        this.addViewInternal(child);
    }

    public void addView(View child, int index, android.view.ViewGroup.LayoutParams params) {
        this.addViewInternal(child);
    }

こんな感じでただaddViewInternal() をコールしているだけ。このaddViewInternal()は何をしているかというと、

    private void addViewInternal(View child) {
        if (child instanceof TabItem) {
            this.addTabFromItemView((TabItem)child);
        } else {
            throw new IllegalArgumentException("Only TabItem instances can be added to TabLayout");
        }
    }

childの型を調べてTabItemだったらaddTabFromItemView()を呼んでいる。

    private void addTabFromItemView(@NonNull TabItem item) {
        TabLayout.Tab tab = this.newTab();
        if (item.text != null) {
            tab.setText(item.text);
        }

        if (item.icon != null) {
            tab.setIcon(item.icon);
        }

        if (item.customLayout != 0) {
            tab.setCustomView(item.customLayout);
        }

        if (!TextUtils.isEmpty(item.getContentDescription())) {
            tab.setContentDescription(item.getContentDescription());
        }

        this.addTab(tab);
    }

ここで面白いのはTabItemはViewをextendsしたclassなのに、ただのdataを保持するだけのクラスのように扱われている。xmlからattributeSetを引回す必要があるのでViewである必要があるのだろうけど。

public class TabItem extends View {
    public final CharSequence text;
    public final Drawable icon;
    public final int customLayout;

    public TabItem(Context context) {
        this(context, (AttributeSet)null);
    }

    public TabItem(Context context, AttributeSet attrs) {
        super(context, attrs);
        TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs, styleable.TabItem);
        this.text = a.getText(styleable.TabItem_android_text);
        this.icon = a.getDrawable(styleable.TabItem_android_icon);
        this.customLayout = a.getResourceId(styleable.TabItem_android_layout, 0);
        a.recycle();
    }
}

生成したTabLayout.Tabに対して保持していたtext, icon, customLayout, contentDescriptionの4種類の情報のみをセットしてaddTab()している。 ここでidが捨てられているので、実際にview hierarchyに追加されたtabをidからfindできない仕組みになっていたのだ。

以下は余談になるが、この辺りは読んでいて色々面白い。

TabLayout.Tabを生成するnewTab()`メソッドはpoolを利用してrecycleしている。

    private static final Pool<TabLayout.Tab> tabPool = new SynchronizedPool(16);

    private final Pool<TabLayout.TabView> tabViewPool;

    public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
...
        this.tabViewPool = new SimplePool(12);
...
    }

    @NonNull
    public TabLayout.Tab newTab() {
        TabLayout.Tab tab = this.createTabFromPool();
        tab.parent = this;
        tab.view = this.createTabView(tab);
        return tab;
    }

    protected TabLayout.Tab createTabFromPool() {
        TabLayout.Tab tab = (TabLayout.Tab)tabPool.acquire();
        if (tab == null) {
            tab = new TabLayout.Tab();
        }

        return tab;
    }

tabPooltabViewPoolのmaxPoolSizeが16と12で異なっているのはなんでなんだろう?

TabLayoutでいい感じにアイコンをつけたい時のメモ

ViewPagerとかと併用してTabLayoutを使う場合に、特定のタブにアイコンを表示する時にどうすればいいかなーという時のメモ。1
今のところは Tab#setCustomView() でcustome layout resourceをセットするのが大体のケースにおいて現実解となりそう。

また、こちらの調査はDesign Support Libraryのコードを呼んだもので、AndroidXでどうなっているかは保証できません。 (ざっと見た感じだとそんなに変わってなさそうだった。2

運よく setIcon() が使える場合

custom viewを作らなくても運よく setIcon() で済む場合がある。setIcon()はその名の通りTabにアイコンを設定してくれる。ただし、このAPIを使う場合だとアイコンの位置はテキストの上に固定されている。

f:id:muumuumuumuu:20190202125807p:plain

ちなみにここのImageViewの定義はこちら。

<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
           android:layout_width="24dp"
           android:layout_height="24dp"
           android:scaleType="centerInside"/>

chromium.googlesource.com

TextViewの定義はこちら。

<TextView xmlns:android="http://schemas.android.com/apk/res/android"
          android:layout_width="wrap_content"
          android:layout_height="wrap_content"
          android:ellipsize="end"
          android:gravity="center"
          android:maxLines="2"/>

chromium.googlesource.com

ちなみにTextViewの方はこちらのstyleが設定されている。

    <style name="TextAppearance.Design.Tab" parent="TextAppearance.AppCompat.Button">
        <item name="android:textSize">14sp</item>
        <item name="android:textColor">?android:textColorSecondary</item>
        <item name="textAllCaps">true</item>
    </style>

chromium.googlesource.com

で、このImageViewとTextViewがTabViewに addView()されるのだが、TabViewLinearLayout をextendsしているので上記のように縦に並んだデザインになる。3

大抵の場合はアイコンをテキスト横に配置したい

縦じゃなくてテキストの横にアイコンを並べたいケースが多いと思う。(こんな感じで↓)

f:id:muumuumuumuu:20190202132334p:plain

この場合だとlayout xmlを書いて setCustomView() に渡してやるとできる。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <ImageView
        android:id="@+id/icon"
        android:layout_width="8dp"
        android:layout_height="8dp"
        android:src="@drawable/circle"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@android:id/text1"/>

    <TextView
        android:id="@android:id/text1"
        style="@style/TabTextAppearance"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@id/icon"
        app:layout_constraintEnd_toEndOf="parent"
        tools:text="TAB"/>
</android.support.constraint.ConstraintLayout>
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val viewPager = findViewById<ViewPager>(R.id.view_pager)
        viewPager.adapter = ViewPagerAdapter(supportFragmentManager)

        val tab = findViewById<TabLayout>(R.id.tab)
        tab.setupWithViewPager(viewPager)
        tab.getTabAt(0)?.setCustomView(R.layout.view_custom_view)
    }

    private class ViewPagerAdapter(fragmentManager: FragmentManager) : FragmentPagerAdapter(fragmentManager) {
        override fun getCount() = 2
        override fun getItem(p0: Int) = MainFragment()
        override fun getPageTitle(position: Int) = when (position) {
            0 -> "one"
            1 -> "two"
            else -> "other"
        }
    }
}

ただし、注意すべきポイントがある。

TextViewのid

上記のxmlの通り、TextViewに設定するidは @android:id/text1 の必要がある。これを設定しないとPagerAdapterから渡されるtitleが設定されない。 TabView#update()の中を見るとわかる。

        final void update() {
            final Tab tab = mTab;
            final View custom = tab.getCustomView();
            if (custom != null) {
                final ViewParent customParent = custom.getParent();
                if (customParent != this) {
                    if (customParent != null) {
                        ((ViewGroup) customParent).removeView(custom);
                    }
                    addView(custom);
                }
                mCustomView = custom;
                if (mTextView != null) {
                    mTextView.setVisibility(GONE);
                }
                if (mIconView != null) {
                    mIconView.setVisibility(GONE);
                    mIconView.setImageDrawable(null);
                }
                mCustomTextView = (TextView) custom.findViewById(android.R.id.text1);
                mCustomIconView = (ImageView) custom.findViewById(android.R.id.icon);

アイコンをつけないViewと見た目を揃えたい

多分一番面倒なのがここ。全てのTabをcustomにするのであればいいけど、デフォルトのtabを一緒に表示する場合は違和感をなくすために見た目を揃えたくなると思う。その場合は上記のsetIcon()を使う場合で紹介したTextViewやstyleのxmlを参考にしてできる限り同じattributeを設定してやると違和感がなくせる。

ただしできないこともある

残念ながらcustom viewをセットする場合はstatus(selectedか否か)をTabViewが設定してくれない。TabViewはdefaultデザインの場合に表示されるmTextView/mIconViewと、custom viewを設定した場合のmCustomTextView/mCustomIconViewを分けて管理している。

        private TextView mTextView;
        private ImageView mIconView;

        private TextView mCustomTextView;
        private ImageView mCustomIconView;

で TabView#setSelected()の中を見ると、mTextView/mIconViewにしかsetSelected()を呼んでいない。

        @Override
        public void setSelected(boolean selected) {
            final boolean changed = (isSelected() != selected);
            super.setSelected(selected);
            if (changed && selected) {
                sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
                if (mTextView != null) {
                    mTextView.setSelected(selected);
                }
                if (mIconView != null) {
                    mIconView.setSelected(selected);
                }
            }
        }

android.googlesource.com

したがって、custom viewを設定した場合はタブを動かしてもfont colorなどが変わらない。

回避策としてTabSelectListerでtabの状態を検知してコードから状態を設定するのが良さそう。

こんな感じでselectorでcolorを定義して、

<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:state_selected="true"
        android:color="?android:textColorPrimary" />
    <item android:color="?android:textColorSecondary" />
</selector>

CustomViewのTextViewのtextColorに指定する。

    <TextView
        android:id="@android:id/text1"
(省略)
        android:textColor="@color/tab_text_color"

最後にTabLayout#addOnTabSelectedListener()を設定してcustomViewの状態を設定する。

        val tab = findViewById<TabLayout>(R.id.tab)
        tab.setupWithViewPager(viewPager)
        tab.getTabAt(0)?.setCustomView(R.layout.view_custom_view)

        tab.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
            override fun onTabReselected(p0: TabLayout.Tab?) {}
            override fun onTabSelected(p0: TabLayout.Tab?) {
                p0?.customView?.isSelected = true
            }
            override fun onTabUnselected(p0: TabLayout.Tab?) {
                p0?.customView?.isSelected = false
            }
        })

これで無事custom Viewの方もstateによってtext colorを変えることができる。

余談

ViewPagerに連動してTabLayoutの方もselected stateが変わるしくみを調べたのでメモ。
ViewPagerとTabLayoutを紐づける時にコールするTabLayout#setupWithViewPager()の内部でViewPagerに対してTabLayoutOnPageChangeListenerをセットしている。

        // Now we'll add our page change listener to the ViewPager
        viewPager.addOnPageChangeListener(new TabLayoutOnPageChangeListener(this));

で、ViewPagerの方でSwipe動作でPageがscrollされた時に上で設定したlistenerのonPageScrolled()が呼ばれる。

    private void dispatchOnPageScrolled(int position, float offset, int offsetPixels) {
        if (this.mOnPageChangeListener != null) {
            this.mOnPageChangeListener.onPageScrolled(position, offset, offsetPixels);
        }

        if (this.mOnPageChangeListeners != null) {
            int i = 0;

            for(int z = this.mOnPageChangeListeners.size(); i < z; ++i) {
                ViewPager.OnPageChangeListener listener = (ViewPager.OnPageChangeListener)this.mOnPageChangeListeners.get(i);
                if (listener != null) {
                    listener.onPageScrolled(position, offset, offsetPixels); // ★ここ
                }
            }
        }

TabLayoutOnPageChangeListener#onPageScrolled()でTabLayout#setScrollPosition()が呼ばれる。

        @Override
        public void onPageScrolled(int position, float positionOffset,
                int positionOffsetPixels) {
            final TabLayout tabLayout = mTabLayoutRef.get();
            if (tabLayout != null) {
                // Update the scroll position, only update the text selection if we're being
                // dragged (or we're settling after a drag)
                final boolean updateText = (mScrollState == SCROLL_STATE_DRAGGING)
                        || (mScrollState == SCROLL_STATE_SETTLING
                        && mPreviousScrollState == SCROLL_STATE_DRAGGING);
                tabLayout.setScrollPosition(position, positionOffset, updateText); // ★ここ
            }
        }

さらにsetSelectedTabView()が呼ばれて、

    public void setScrollPosition(int position, float positionOffset, boolean updateSelectedText) {
        if (mIndicatorAnimator != null && mIndicatorAnimator.isRunning()) {
            return;
        }
        if (position < 0 || position >= mTabStrip.getChildCount()) {
            return;
        }
        // Set the indicator position and update the scroll to match
        mTabStrip.setIndicatorPositionFromTabPosition(position, positionOffset);
        scrollTo(calculateScrollXForTab(position, positionOffset), 0);
        // Update the 'selected state' view as we scroll
        if (updateSelectedText) {
            setSelectedTabView(Math.round(position + positionOffset)); // ★ここ
        }
    }

この中でTabView#setSelected()をコールし、

    private void setSelectedTabView(int position) {
        final int tabCount = mTabStrip.getChildCount();
        for (int i = 0; i < tabCount; i++) {
            final View child = mTabStrip.getChildAt(i);
            child.setSelected(i == position);  // ★ここ
        }
    }

それぞれのViewに対して状態を変えている。先ほども見た通り、custom viewの状態は変わらない。

        @Override
        public void setSelected(boolean selected) {
            final boolean changed = (isSelected() != selected);
            super.setSelected(selected);
            if (changed && selected) {
                sendAccessibilityEvent(AccessibilityEvent.TYPE_VIEW_SELECTED);
                if (mTextView != null) {
                    mTextView.setSelected(selected);
                }
                if (mIconView != null) {
                    mIconView.setSelected(selected);
                }
            }
        }

  1. バッジを出したいとかよく見るUIな気がする

  2. github.com

  3. orientationを指定していないのでdefaultのverticalが適用されて縦に並ぶ

便利なTextInputLayoutとその内部実装

はじめに

Material Designにいつの間にか入っていたTextInputLayoutが便利なのでメモを残す。

TextInputLayout とは

公式ドキュメント によると、TextInputLayout とは「テキストを入力する時にhintを隠す代わりにfloating labelを表示するEditTextをラップしたview」らしい。 これだけ読んでも謎だと思うので、Google Code LabのMaterial Design Componentsのコースをgit cloneしてきて動かしてみるとわかりやすいです。 あとは最近WebのGoogle Login画面でこのcomponentが使われている気がする。

MDC-104 Android: Material Advanced Components (Kotlin)

なんかgifがうまくアップロードできなくてanimation部分が見えない…

f:id:muumuumuumuu:20190104182847g:plain:w200

ちなみに上記はstyleにWidget.MaterialComponents.TextInputLayout.OutlineBoxが設定されている。

どうやってhintを動かしているのか?

せっかくなのでこのhintのanimationがどのように実装されているのかコードを読んでみた。 ちなみに上記のCode Labからgit cloneしてきたプロジェクトだとTextInputLayout はAndroidXではなく com.android.support:support-v4:28.0.0-alpha3 だったので、これを読んでいく。

animationしているのは下記のメソッド。このメソッドの引数のfloatには、collapseの場合は1.0F, expandの時は0.0Fが渡される。

    @VisibleForTesting
    void animateToExpansionFraction(float target) {
        if (this.collapsingTextHelper.getExpansionFraction() != target) {
            if (this.animator == null) {
                this.animator = new ValueAnimator();
                this.animator.setInterpolator(AnimationUtils.FAST_OUT_SLOW_IN_INTERPOLATOR);
                this.animator.setDuration(167L);
                this.animator.addUpdateListener(new AnimatorUpdateListener() {
                    public void onAnimationUpdate(ValueAnimator animator) {
                        TextInputLayout.this.collapsingTextHelper.setExpansionFraction((Float)animator.getAnimatedValue());
                    }
                });
            }

            this.animator.setFloatValues(new float[]{this.collapsingTextHelper.getExpansionFraction(), target});
            this.animator.start();
        }
    }

ValueAnimator を使ってanimationしている。Interpolatorを設定して167Lのdurationをセット。(なんで167に決まったんだろう?)
で現在のexpansion fractionをHelperクラスからとってきて、そこから引数のtargetまでをvalueの変更幅に指定する。

start()を読んでやるとlistenerに設定したcallback methodが呼ばれる。

実際にviewをanimationするのはまたもやhelperクラスの方に移譲している。

    public void setExpansionFraction(float fraction) {
        fraction = MathUtils.clamp(fraction, 0.0F, 1.0F);
        if (fraction != this.expandedFraction) {
            this.expandedFraction = fraction;
            this.calculateCurrentOffsets();
        }

    }
    private void calculateOffsets(float fraction) {
        this.interpolateBounds(fraction);
        this.currentDrawX = lerp(this.expandedDrawX, this.collapsedDrawX, fraction, this.positionInterpolator);
        this.currentDrawY = lerp(this.expandedDrawY, this.collapsedDrawY, fraction, this.positionInterpolator);
        this.setInterpolatedTextSize(lerp(this.expandedTextSize, this.collapsedTextSize, fraction, this.textSizeInterpolator));
        if (this.collapsedTextColor != this.expandedTextColor) {
            this.textPaint.setColor(blendColors(this.getCurrentExpandedTextColor(), this.getCurrentCollapsedTextColor(), fraction));
        } else {
            this.textPaint.setColor(this.getCurrentCollapsedTextColor());
        }

        this.textPaint.setShadowLayer(lerp(this.expandedShadowRadius, this.collapsedShadowRadius, fraction, (TimeInterpolator)null), lerp(this.expandedShadowDx, this.collapsedShadowDx, fraction, (TimeInterpolator)null), lerp(this.expandedShadowDy, this.collapsedShadowDy, fraction, (TimeInterpolator)null), blendColors(this.expandedShadowColor, this.collapsedShadowColor, fraction));
        ViewCompat.postInvalidateOnAnimation(this.view);
    }

currentDrawXcurrentDrawYそのほか諸々の計算を済ませておいてpostInvalidateOnAnimation()をコールして描画し直す時にその値を使う感じっぽい。 ViewCompat.postInvalidateOnAnimation() を辿っていくとView#invalidate() が呼ばれていることがわかる。

postInvalidateOnAnimation() の引数にはTextInputLayoutが渡されているので、このクラスのdraw()をみてみるとhelperクラスのdraw()が呼ばれている。

    public void draw(Canvas canvas) {
        if (this.boxBackground != null) {
            this.boxBackground.draw(canvas);
        }

        super.draw(canvas);
        if (this.hintEnabled) {
            this.collapsingTextHelper.draw(canvas);
        }

    }

この中でcalculateOffsets() で計算したx,yの値やsetInterpolatedTextSize()の内部で計算・設定されているscaleを使ってviewを動かしている。 動かすのはcanvasに対してscale()とかdrawBitmap()/drawText()とかをコールして実装している。
(あまり関係ないけどvar10000とかvar7の変数はなんのために作られたのかわからない…)

    public void draw(Canvas canvas) {
        int saveCount = canvas.save();
        if (this.textToDraw != null && this.drawTitle) {
            float x = this.currentDrawX;
            float y = this.currentDrawY;
            boolean drawTexture = this.useTexture && this.expandedTitleTexture != null;
            float ascent;
            if (drawTexture) {
                ascent = this.textureAscent * this.scale;
                float var10000 = this.textureDescent * this.scale;
            } else {
                ascent = this.textPaint.ascent() * this.scale;
                float var7 = this.textPaint.descent() * this.scale;
            }

            if (drawTexture) {
                y += ascent;
            }

            if (this.scale != 1.0F) {
                canvas.scale(this.scale, this.scale, x, y);
            }

            if (drawTexture) {
                canvas.drawBitmap(this.expandedTitleTexture, x, y, this.texturePaint);
            } else {
                canvas.drawText(this.textToDraw, 0, this.textToDraw.length(), x, y, this.textPaint);
            }
        }

        canvas.restoreToCount(saveCount);
    }

アニメーションが早すぎて見えないけどTextColorもアニメーションに合わせて徐々に変わるように設定されていて細かい。すごい。でも見えない。

    private static int blendColors(int color1, int color2, float ratio) {
        float inverseRatio = 1.0F - ratio;
        float a = (float)Color.alpha(color1) * inverseRatio + (float)Color.alpha(color2) * ratio;
        float r = (float)Color.red(color1) * inverseRatio + (float)Color.red(color2) * ratio;
        float g = (float)Color.green(color1) * inverseRatio + (float)Color.green(color2) * ratio;
        float b = (float)Color.blue(color1) * inverseRatio + (float)Color.blue(color2) * ratio;
        return Color.argb((int)a, (int)r, (int)g, (int)b);
    }