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

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

selectableItemBackgroundはBackgroundにセットしないと意図した挙動にならない話

タイトルが意味わからないことになっている😇

はじめに

AndroidAPI Level 21 (Android 5.0 / Lollipop) からRipple Effectがサポートされ、これによりより良いタッチフィードバックをユーザに提供できることができる。ボタンなど、「もともとタップが想定されているView」については、タップした際にデフォルトでRipple Effectが表示されている。ただのViewだったりTextViewなど「デフォルトでタップが想定されていないView」についてもシステムが用意したdrawable resourceを設定することで簡単に実現できる。

システムが用意したdrawable resourceは(自分の知る限り)2種類ある。

  • ?android:attr/selectableItemBackgroundBorderless
  • ?android:attr/selectableItemBackground

これをViewのbackground/foregroundにセットするかでまた挙動が変わるという面白い現象を見つけたので調べてみた。

組み合わせでどう変わる?

まずは2種類のresourceの説明をそれぞれしていこう。Ripple Effectのcolorやdurationは変わらないのだが、selectableItemBackgroundBorderlessの方はその名の通りBorderless、つまりViewのboundaryを超えてRipple Effectを表示することができる。幅・高さが小さいViewに対してタッチフィードバックをつけるのに大変便利だ。ただし、ViewのBackgroundにセットした場合のみで、Foregroundにセットした時にはselectableItemBackgroundと同じ挙動になる。

f:id:muumuumuumuu:20181014155223g:plain:w300

(上記のサンプルコードはリンクを参照)
さて、それぞれどうして差が出るのかコードを追っていこう。

それぞれ指定されたResourceは何をやっているのか?

Borderless

selectableItemBackgroundBorderlessを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight" />

Borderlessじゃない方

selectableItemBackgroundを指定した場合、最終的にこのxmlが読み込まれる。

     17 <ripple xmlns:android="http://schemas.android.com/apk/res/android"
     18     android:color="?attr/colorControlHighlight">
     19     <item android:id="@id/mask">
     20         <color android:color="@color/white" />
     21     </item>
     22 </ripple>

これらのrippleタグは最終的にRippleDrawableに変換される。

BackgroundとForegroundで挙動が異なる

RippleDrawableクラスの公式ドキュメントを見ると下記のような記載がある。

If no child layers or mask is specified and the ripple is set as a View background, the ripple will be drawn atop the first available parent background within the View's hierarchy. In this case, the drawing region may extend outside of the Drawable bounds.

わざわざ "as a View background" と書いてあるように、backgroundに指定した場合のみViewのhierarchyを辿ってparentのbackground内まで描画することができるようだ。

せっかくなのでコードを読んでみよう

RippleDrawableはLayerDrawableをextendsしているのでlayerを重ねることができる。これでmaskをかけている。

    189     public RippleDrawable(@NonNull ColorStateList color, @Nullable Drawable content,
    190             @Nullable Drawable mask) {
    191         this(new RippleState(null, null, null), null);


    201         if (mask != null) {
    202             addLayer(mask, null, android.R.id.mask, 0, 0, 0, 0);
    203         }

このmastが影響してくるのはdraw()の時

    688     @Override
    689     public void draw(@NonNull Canvas canvas) {
    690         pruneRipples();
    691 
    692         // Clip to the dirty bounds, which will be the drawable bounds if we
    693         // have a mask or content and the ripple bounds if we're projecting.
    694         final Rect bounds = getDirtyBounds();
    695         final int saveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
    696         canvas.clipRect(bounds);
    697 
    698         drawContent(canvas);
    699         drawBackgroundAndRipples(canvas);
    700 
    701         canvas.restoreToCount(saveCount);
    702     }

getDirtyBounds() はmaskがある時とないときで挙動が変わる

    922     @Override
    923     public Rect getDirtyBounds() {
    924         if (!isBounded()) {
    925             final Rect drawingBounds = mDrawingBounds;
    926             final Rect dirtyBounds = mDirtyBounds;
    927             dirtyBounds.set(drawingBounds);
    928             drawingBounds.setEmpty();
    929 
    930             final int cX = (int) mHotspotBounds.exactCenterX();
    931             final int cY = (int) mHotspotBounds.exactCenterY();
    932             final Rect rippleBounds = mTempRect;
    933 
    934             final RippleForeground[] activeRipples = mExitingRipples;
    935             final int N = mExitingRipplesCount;
    936             for (int i = 0; i < N; i++) {
    937                 activeRipples[i].getBounds(rippleBounds);
    938                 rippleBounds.offset(cX, cY);
    939                 drawingBounds.union(rippleBounds);
    940             }
    941 
    942             final RippleBackground background = mBackground;
    943             if (background != null) {
    944                 background.getBounds(rippleBounds);
    945                 rippleBounds.offset(cX, cY);
    946                 drawingBounds.union(rippleBounds);
    947             }
    948 
    949             dirtyBounds.union(drawingBounds);
    950             dirtyBounds.union(super.getDirtyBounds());
    951             return dirtyBounds;
    952         } else {
    953             return getBounds();
    954         }
    955     }

さて、ここまででselectableItemBackgroundBorderlessselectableItemBackgroundの違いがわかった。 次はBackgroundとForegroundで挙動が変わるところをみてみよう。

RippleDrawable#isProjected()というメソッドの中で、タップされた場所から円を描いて自分のサイズに収まるかどうかをみている。maskされたlayerが存在する場合もfalseを返している。

  344     @Override
    345     public boolean isProjected() {
    346         // If the layer is bounded, then we don't need to project.
    347         if (isBounded()) {
    348             return false;
    349         }
    350 
    351         // Otherwise, if the maximum radius is contained entirely within the
    352         // bounds then we don't need to project. This is sort of a hack to
    353         // prevent check box ripples from being projected across the edges of
    354         // scroll views. It does not impact rendering performance, and it can
    355         // be removed once we have better handling of projection in scrollable
    356         // views.
    357         final int radius = mState.mMaxRadius;
    358         final Rect drawableBounds = getBounds();
    359         final Rect hotspotBounds = mHotspotBounds;
    360         if (radius != RADIUS_AUTO
    361                 && radius <= hotspotBounds.width() / 2
    362                 && radius <= hotspotBounds.height() / 2
    363                 && (drawableBounds.equals(hotspotBounds)
    364                         || drawableBounds.contains(hotspotBounds))) {
    365             return false;
    366         }
    367 
    368         return true;
    369     }

このメソッドが呼ばれるのはViewクラスのgetDrawableRenderNode().

   19433     private RenderNode getDrawableRenderNode(Drawable drawable, RenderNode renderNode) {
   19434         if (renderNode == null) {
   19435             renderNode = RenderNode.create(drawable.getClass().getName(), this);
   19436         }

   19457         renderNode.setProjectBackwards(drawable.isProjected());

ここでsetしたProjectBackwardsが呼ばれるのはこの辺?(Nativeコード詳しくないマンなので間違ってたらごめんなさい)

    101 void RenderNodeDrawable::forceDraw(SkCanvas* canvas) {


    120     //pass this outline to the children that may clip backward projected nodes
    121     displayList->mProjectedOutline = displayList->containsProjectionReceiver()
    122             ? &properties.getOutline() : nullptr;
    123     if (!properties.getProjectBackwards()) {
    124         drawContent(canvas);
    125         if (mProjectedDisplayList) {
    126             acr.restore(); //draw projected children using parent matrix
    127             LOG_ALWAYS_FATAL_IF(!mProjectedDisplayList->mProjectedOutline);
    128             const bool shouldClip = mProjectedDisplayList->mProjectedOutline->getPath();
    129             SkAutoCanvasRestore acr2(canvas, shouldClip);
    130             canvas->setMatrix(mProjectedDisplayList->mProjectedReceiverParentMatrix);
    131             if (shouldClip) {
    132                 clipOutline(*mProjectedDisplayList->mProjectedOutline, canvas, nullptr);
    133             }
    134             drawBackwardsProjectedNodes(canvas, *mProjectedDisplayList);
    135         }
    136     }
    137     displayList->mProjectedOutline = nullptr;
    138 }

確かにProjectedされたoutlineをclipしているように見える。

で、話を戻してViewクラスのgetDrawableRenderNode()がいつ呼ばれるかというと、View#drawBackground()からのみコールされている。

   19375     private void drawBackground(Canvas canvas) {
   19376         final Drawable background = mBackground;
   19377         if (background == null) {
   19378             return;
   19379         }
   19380 
   19381         setBackgroundBounds();
   19382 
   19383         // Attempt to use a display list if requested.
   19384         if (canvas.isHardwareAccelerated() && mAttachInfo != null
   19385                 && mAttachInfo.mThreadedRenderer != null) {
   19386             mBackgroundRenderNode = getDrawableRenderNode(background, mBackgroundRenderNode);
   19387 
   19388             final RenderNode renderNode = mBackgroundRenderNode;
   19389             if (renderNode != null && renderNode.isValid()) {
   19390                 setBackgroundRenderNodeProperties(renderNode);
   19391                 ((DisplayListCanvas) canvas).drawRenderNode(renderNode);
   19392                 return;
   19393             }
   19394         }

というわけでbackgroundとforegruondで挙動に差分が出るのはここでした。解決!