selectableItemBackgroundはBackgroundにセットしないと意図した挙動にならない話
タイトルが意味わからないことになっている😇
はじめに
AndroidはAPI Level 21 (Android 5.0 / Lollipop) からRipple Effectがサポートされ、これによりより良いタッチフィードバックをユーザに提供できることができる。ボタンなど、「もともとタップが想定されているView」については、タップした際にデフォルトでRipple Effectが表示されている。ただのViewだったりTextViewなど「デフォルトでタップが想定されていないView」についてもシステムが用意したdrawable resourceを設定することで簡単に実現できる。
システムが用意したdrawable resourceは(自分の知る限り)2種類ある。
これをViewのbackground/foregroundにセットするかでまた挙動が変わるという面白い現象を見つけたので調べてみた。
組み合わせでどう変わる?
まずは2種類のresourceの説明をそれぞれしていこう。Ripple Effectのcolorやdurationは変わらないのだが、selectableItemBackgroundBorderless
の方はその名の通りBorderless、つまりViewのboundaryを超えてRipple Effectを表示することができる。幅・高さが小さいViewに対してタッチフィードバックをつけるのに大変便利だ。ただし、ViewのBackgroundにセットした場合のみで、Foregroundにセットした時にはselectableItemBackground
と同じ挙動になる。
(上記のサンプルコードはリンクを参照)
さて、それぞれどうして差が出るのかコードを追っていこう。
それぞれ指定された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 }
さて、ここまででselectableItemBackgroundBorderless
とselectableItemBackground
の違いがわかった。
次は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で挙動に差分が出るのはここでした。解決!