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

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

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が適用されて縦に並ぶ