참고자료 : Create Shadows and Clip Views | Android Developers ,
AAPT: error: attribute android:clipToOutline not found ,
RenderNode | Android Developers
Outline 내부 동작
먼저 outline 이 내부적으로 어떻게 동작하는지 확인해보겠습니다. (sdk level 31 기준)
View.java
@CallSuper
protected void onAttachedToWindow() {
if ((mPrivateFlags & PFLAG_REQUEST_TRANSPARENT_REGIONS) != 0) {
mParent.requestTransparentRegion(this);
}
mPrivateFlags3 &= ~PFLAG3_IS_LAID_OUT;
jumpDrawablesToCurrentState();
AccessibilityNodeIdManager.getInstance().registerViewWithId(this, getAccessibilityViewId());
resetSubtreeAccessibilityStateChanged();
// rebuild, since Outline not maintained while View is detached
rebuildOutline(); 1️⃣
if (isFocused()) {
notifyFocusChangeToImeFocusController(true /* hasFocus */);
}
}
1️⃣ View 에서 onAttachedToWindow 에서 rebuildOutline(); 이 불리게 됩니다.
View.java
...
ViewOutlineProvider mOutlineProvider = ViewOutlineProvider.BACKGROUND; 3️⃣
...
final static class AttachInfo {
...
/**
* Temporary for use in querying outlines from OutlineProviders
*/
final Outline mTmpOutline = new Outline();
...
}
...
private void rebuildOutline() {
// Unattached views ignore this signal, and outline is recomputed in onAttachedToWindow()
if (mAttachInfo == null) return;
if (mOutlineProvider == null) { 2️⃣
// no provider, remove outline
mRenderNode.setOutline(null);
} else {
final Outline outline = mAttachInfo.mTmpOutline; 4️⃣
outline.setEmpty(); 5️⃣
outline.setAlpha(1.0f);
mOutlineProvider.getOutline(this, outline); 6️⃣
mRenderNode.setOutline(outline); 9️⃣
}
}
2️⃣ rebuildOutline 에서 mOutlineProvider 가 null 이면 mRenderNode.setOutline(null); 를 호출합니다.
3️⃣ 기본적으로 mOutlineProvider 는 멤버변수로 ViewOutlineProvider.BACKGROUND 값이 할당되어 있습니다.
mOutlineProvider 가 null 이 아니기 때문에 이하 코드가 호출됩니다.
4️⃣ 임시 outline class 를 가져와 5️⃣ setEmpty 와 setAlpha 를 호출합니다.
Outline 의 setEmpty 및 setAlpha 메서드는 다음과 같습니다.
Outline.java
public void setEmpty() {
if (mPath != null) {
// rewind here to avoid thrashing the allocations, but could alternately clear ref
mPath.rewind();
}
mMode = MODE_EMPTY;
mRect.setEmpty();
mRadius = RADIUS_UNDEFINED;
}
public void setAlpha(@FloatRange(from=0.0, to=1.0) float alpha) {
mAlpha = alpha;
}
6️⃣ 다시 VIew 로 돌아가서 mOutlineProvider.getOutline 으로 outline 을 가져옵니다.
ViewOutlineProvider 클래스
ViewOutlineProvider.java
public abstract class ViewOutlineProvider {
public static final ViewOutlineProvider BACKGROUND = new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
Drawable background = view.getBackground(); 7️⃣
if (background != null) {
background.getOutline(outline);
} else {
outline.setRect(0, 0, view.getWidth(), view.getHeight());
outline.setAlpha(0.0f);
}
}
};
public static final ViewOutlineProvider BOUNDS = new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRect(0, 0, view.getWidth(), view.getHeight());
}
};
public static final ViewOutlineProvider PADDED_BOUNDS = new ViewOutlineProvider() {
@Override
public void getOutline(View view, Outline outline) {
outline.setRect(view.getPaddingLeft(),
view.getPaddingTop(),
view.getWidth() - view.getPaddingRight(),
view.getHeight() - view.getPaddingBottom());
}
};
7️⃣ 현재 mOutlineProvider 는 ViewOutlineProvider BACKGROUND 이기 때문에 view 의 background Drawable 을 가져와서 null 이 아니면 8️⃣ background Drawable의 getOutline 을 호출하고 null 이면 outline 에 view의 크기만큼(width, height) rect 를 set 하고 alpha 값을 0으로 할당합니다.
Drawable 의 getOutline 코드는 다음과 같습니다.
Drwable.java
public void getOutline(@NonNull Outline outline) { 8️⃣
outline.setRect(getBounds());
outline.setAlpha(0);
}
9️⃣ 다시 View로 돌아가서 mRenderNode.setOutline(outline) 을 호출합니다.
RenderNode 코드는 다음과 같습니다.
RenderNode.java
public boolean setOutline(@Nullable Outline outline) {
if (outline == null) {
return nSetOutlineNone(mNativeRenderNode);
}
switch (outline.mMode) {
case Outline.MODE_EMPTY: 🔟
return nSetOutlineEmpty(mNativeRenderNode);
case Outline.MODE_ROUND_RECT:
return nSetOutlineRoundRect(mNativeRenderNode,
outline.mRect.left, outline.mRect.top,
outline.mRect.right, outline.mRect.bottom,
outline.mRadius, outline.mAlpha);
case Outline.MODE_PATH:
return nSetOutlinePath(mNativeRenderNode, outline.mPath.mNativePath,
outline.mAlpha);
}
throw new IllegalArgumentException("Unrecognized outline?");
}
@CriticalNative
private static native boolean nSetOutlineRoundRect(long renderNode, int left, int top,
int right, int bottom, float radius, float alpha);
위에서 5️⃣ outline.setEmpty() 에서 🔟 MODE_EMPTY 가 set 되었기 때문에 nSetOutlineEmpty native 함수가 호출됩니다.
참고 : RenderNode
RenderNode는 하드웨어 가속 렌더링 계층을 구축하는 데 사용됩니다. 각 RenderNode에는 display list 와 display list 의 렌더링에 영향을 주는 속성 집합이 모두 포함되어 있습니다. RenderNode는 기본적으로 모든 view 에 대해 내부적으로 사용되며 일반적으로 직접 사용되지 않습니다.
RenderNode는 복잡한 장면의 렌더링 콘텐츠를 더 저렴한 비용으로 개별적으로 업데이트할 수 있는 더 작은 조각으로 나누는 데 사용됩니다. 장면의 일부를 업데이트하려면 처음부터 모든 것을 다시 그리는 대신 display list 나 소수의 RenderNode 속성만 업데이트하면 됩니다. RenderNode는 콘텐츠만 변경해야 하는 경우에만 display list 을 다시 기록해야 합니다. 변환 속성을 통해 display list 을 다시 기록하지 않고도 RenderNode를 변환할 수도 있습니다.
예를 들어 텍스트 편집기는 각 단락을 자체 RenderNode에 저장할 수 있습니다. 따라서 사용자가 문자를 삽입하거나 제거할 때 영향을 받는 단락의 표시 display list 만 다시 기록하면 됩니다.
하드웨어 가속
RecordingCanvas 를 사용하여 RenderNode를 그릴 수 있습니다 . 소프트웨어에서는 지원되지 않습니다. display list 을 렌더링하는 데 사용하는 캔버스가 Canvas.isHardwareAccelerated()를 사용하여 하드웨어 가속되었는지 항상 확인하십시오.
Outline 을 이용한 구현 방법
- ViewOutlineProvider 를 상속받은 CustomOutlineProvider 구현합니다.
- 구현한 CustomOutlineProvider 를 target View에 set 해줍니다.
- view 의 setClipToOutline 값을 true 로 설정합니다.
- CustomOutlineProvider
getOutline 을 구현한 CustomOutlineProvider 코드는 아래와 같습니다.
CustomOutlineProvider.kt
class CustomOutlineProvider(private val radius: Float) : ViewOutlineProvider() {
override fun getOutline(view: View?, outline: Outline?) {
if (view == null) return
val cornerRadiusDP = radius
val left = 0
val top = 0
val right = view.width
val bottom = view.height
val cornerRadius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
cornerRadiusDP,
view.resources?.displayMetrics
)
outline?.setRoundRect(left, top, right, bottom, cornerRadius) 👈
}
}
👈 CustomOutlineProvider 의 getOutline 에서 setRoundRect 를 호출합니다.
Outline 의 setRoundRect 코드는 다음과 같습니다.
Outline.java
public void setRoundRect(int left, int top, int right, int bottom, float radius) {
if (left >= right || top >= bottom) {
setEmpty();
return;
}
if (mMode == MODE_PATH) {
// rewind here to avoid thrashing the allocations, but could alternately clear ref
mPath.rewind();
}
mMode = MODE_ROUND_RECT; 👈
mRect.set(left, top, right, bottom);
mRadius = radius;
}
👈 setRoundRect 에서 mMode 를 MODE_ROUND_RECT 로 변경하고, mRect 및 mRadius 를 set 합니다.
내부동작에서 위의 RenderNode 코드를 보면 🔟 에서 MODE_ROUND_RECT 일 때는 nSetOutlineRoundRect 메서드가 호출됩니다. native 메서드라 내부를 볼 수는 없지만 여기서 round 효과를 주는 것임을 추측할 수 있습니다.
2. targetView 에 customOutlineProvider set
fun View.setCustomOutline(radius: Float) {
val outlineProvider = CustomOutlineProvider(radius)
this.outlineProvider = outlineProvider 1️⃣
this.clipToOutline = true
}
extension 으로 View 에 outlineProvider 를 radius 를 인자로 받아 생성하고 set 하도록 하였습니다.
1️⃣ outlineProvider 를 set 하면 다음이 호출됩니다.
View.java
public void setOutlineProvider(ViewOutlineProvider provider) {
mOutlineProvider = provider;
invalidateOutline(); 2️⃣
}
2️⃣ outlineProvider 를 mOutlineProvider 에 할당하고 invalidateOutline 함수를 호출합니다.
invalidateOutline 은 다음과 같습니다.
View.java
public void invalidateOutline() {
rebuildOutline();
notifySubtreeAccessibilityStateChangedIfNeeded();
invalidateViewProperty(false, false);
}
내부동작에서 설명한 rebuildOutline 이 호출됩니다.
3. view 의 setClipToOutline 값을 true 로 설정합니다
fun View.setCustomOutline(radius: Float) {
val outlineProvider = CustomOutlineProvider(radius)
this.outlineProvider = outlineProvider
this.clipToOutline = true 3️⃣
}
3️⃣ 에서 setClipToOutline 함수를 호출합니다.
setClipToOutline 함수는 다음과 같습니다.
View.java
public void setClipToOutline(boolean clipToOutline) {
damageInParent(); 4️⃣
if (getClipToOutline() != clipToOutline) { 5️⃣
mRenderNode.setClipToOutline(clipToOutline);
}
}
4️⃣ damageInParent 에서 자식 view 가 invalidated 되었음을 알립니다.
damageInParent 코드는 다음과 같습니다.
/**
* Tells the parent view to damage this view's bounds.
*
* @hide
*/
protected void damageInParent() {
if (mParent != null && mAttachInfo != null) {
mParent.onDescendantInvalidated(this, this);
}
}
5️⃣ getClipToOutline 값과 인자 값이 다르면 mRenderNode 에 새로운 값을 할당해줍니다.
RenderNode.java
public boolean setClipToOutline(boolean clipToOutline) {
return nSetClipToOutline(mNativeRenderNode, clipToOutline);
}
장점 : 간단하게 extension 함수를 통해 필요한 View에 적용가능합니다.
단점 : 적용해야 하는 View 가 많을수록 코드 내에서 호출해야 합니다.
View 의 Outline 속성을 이용하는 방법
view 에는 outline 과 관련된 2가지 속성이 있습니다.
outlineProvider 와 clipToOutline 입니다.
View 생성자에서 다음과 같이 맵핑됩니다.
View.java
public View(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) {
this(context);
...
case R.styleable.View_outlineProvider:
setOutlineProviderFromAttribute(a.getInt(R.styleable.View_outlineProvider,
PROVIDER_BACKGROUND));
...
case R.styleable.View_clipToOutline:
setClipToOutline(a.getBoolean(attr, false));
break;
- outlineProvider
사용예시는 다음과 같습니다.
android:outlineProvider="background"
background, bounds, paddedBounds, none 4가지 선택이 가능합니다.
선택된 값은 아래 코드와 같이 맵핑됩니다.
View.java
// correspond to the enum values of View_outlineProvider
private static final int PROVIDER_BACKGROUND = 0;
private static final int PROVIDER_NONE = 1;
private static final int PROVIDER_BOUNDS = 2;
private static final int PROVIDER_PADDED_BOUNDS = 3;
private void setOutlineProviderFromAttribute(int providerInt) {
switch (providerInt) {
case PROVIDER_BACKGROUND:
setOutlineProvider(ViewOutlineProvider.BACKGROUND);
break;
case PROVIDER_NONE:
setOutlineProvider(null);
break;
case PROVIDER_BOUNDS:
setOutlineProvider(ViewOutlineProvider.BOUNDS);
break;
case PROVIDER_PADDED_BOUNDS:
setOutlineProvider(ViewOutlineProvider.PADDED_BOUNDS);
break;
}
}
- clipToOutline
사용예시는 다음과 같습니다.
android:clipToOutline="true"
한계점 :
- round outline 을 만들수 없습니다. (지정된 형태의 outline 만 가능합니다)
- clipToOutline 은 sdk level 31 이상에서 가능합니다.
- sdk level 30 View.java 에는 다음의 코드가 없습니다.
case R.styleable.View_clipToOutline:
setClipToOutline(a.getBoolean(attr, false));
break;
CustomView 에 outline Provider 를 set 하는 방법
CustomView 의 속성값에 radius 를 받도록 해서 생성자에서 CustomOutlineProvider 를 생성하여 set 해주는 방법이 있습니다.
장점 : 코드 내에서 extension 함수를 호출해서 view 마다 setCustomOutline 함수를 호출할 필요가 없습니다.
단점 : 필요에 따라 CustomView 를 여러개 만들어야 합니다.
Outline 을 재설정하는 rebuildOutline 이 호출되는 시점
rebuildOutline 이 호출되는 시점은 다음과 같습니다.
- invalidateOutline 내에서
- onAttachedToWindow 내에서
- setBackgroundBounds 내에서
- sizeChange 내에서
- invalidateDrawable 내에서
invalidateOutline 이 호출되는 시점 (View 내로 한정할 때)은 다음과 같습니다.
- setOutlineProvider 내에서
- setBackgroundDrawable 내에서
- internalSetPadding 내에서 (maxTargetSdk = Build.VERSION_CODES.P)
ClipChildren
ClipChildren 은 자식 view 가 부모 view 의 영역까지 사용하게 해주는 속성입니다.
내부 동작에 대해서 알아보겠습니다.
layout 의 cilpChidren 속성을 false 로 수정하면 내부적으로 setClipChildren 호출됩니다.
ViewGroup.java
public void setClipChildren(boolean clipChildren) {
boolean previousValue = (mGroupFlags & FLAG_CLIP_CHILDREN) == FLAG_CLIP_CHILDREN;
if (clipChildren != previousValue) {
setBooleanFlag(FLAG_CLIP_CHILDREN, clipChildren); 👈
for (int i = 0; i < mChildrenCount; ++i) {
View child = getChildAt(i);
if (child.mRenderNode != null) {
child.mRenderNode.setClipToBounds(clipChildren);
}
}
invalidate(true);
}
}
👈 FLAG_CLIP_CHILDREN 값이 false 로 바뀌게 됩니다.
View.java
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
...
if (!drawingWithRenderNode) {
// apply clips directly, since RenderNode won't do it for this draw
👉 if ((parentFlags & ViewGroup.FLAG_CLIP_CHILDREN) != 0 && cache == null) {
if (offsetForScroll) {
canvas.clipRect(sx, sy, sx + getWidth(), sy + getHeight());
} else {
if (!scalingRequired || cache == null) {
canvas.clipRect(0, 0, getWidth(), getHeight());
} else {
canvas.clipRect(0, 0, cache.getWidth(), cache.getHeight());
}
}
}
👉 draw 함수 내에서 부모 속성이 FLAG_CLIP_CHILDREN 가 true 이면 clipRect 를 호출하며 canvas 를 자신의 영역만큼 canvas 를 clip합니다. 해당 값이 false 이기 때문에 canvas.clipRect 가 호출되지 않아 위 처럼 부모 canvas 영역을 그릴수 있게됩니다.
ClipToPadding
ClipToPadding 은 Padding 영역만큼 사용할 수 있게 해주는 속성입니다.
내부 동작에 대해서 알아보겠습니다.
clipToPadding 속성을 false 로 수정하면 내부적으로 setClipToPadding 이 호출됩니다.
ViewGroup.java
👉 protected static final int CLIP_TO_PADDING_MASK = FLAG_CLIP_TO_PADDING | FLAG_PADDING_NOT_NULL;
...
public void setClipToPadding(boolean clipToPadding) {
if (hasBooleanFlag(FLAG_CLIP_TO_PADDING) != clipToPadding) {
setBooleanFlag(FLAG_CLIP_TO_PADDING, clipToPadding);
invalidate(true);
}
}
👉 FLAG_CLIP_TO_PADDING 값은 멤버변수인 CLIP_TO_PADDING_MASK 로 사용됩니다. (padding 존재 여부 및 clipToPadding 값)
ViewGroup.java
@Override
protected void dispatchDraw(Canvas canvas) {
...
int clipSaveCount = 0;
👉 final boolean clipToPadding = (flags & CLIP_TO_PADDING_MASK) == CLIP_TO_PADDING_MASK;
if (clipToPadding) {
clipSaveCount = canvas.save(Canvas.CLIP_SAVE_FLAG);
canvas.clipRect(mScrollX + mPaddingLeft, mScrollY + mPaddingTop,
mScrollX + mRight - mLeft - mPaddingRight,
mScrollY + mBottom - mTop - mPaddingBottom);
}
👉 CLIP_TO_PADDING_MASK 는 dispatchDraw 에서 체크 되고 clipToPadding 이면 padding 만큼 canvas 를 clip합니다.