高级-UI-从零到整-(三)-理解-View-工作原理并带你入门自定义-View

//WindowManagerImpl.java
@Override
public void addView(@NonNull View view, @NonNull ViewGroup.LayoutParams params) {
applyDefaultToken(params);
/**

  • 委托给 WindowManagerGlobal 来处理 addView
    */
    mGlobal.addView(view, params, mContext.getDisplay(), mParentWindow);
    }
    复制代码

内部处理又交给了 WindowManagerGlobal 对象,我们继续跟踪,代码如下:

//WindowManagerGlobal.java
/**
*

  • @param view DecorView
  • @param params
  • @param display
  • @param parentWindow
    */
    public void addView(View view, ViewGroup.LayoutParams params,
    Display display, Window parentWindow) {
    if (view == null) {
    throw new IllegalArgumentException(“view must not be null”);
    }
    if (display == null) {
    throw new IllegalArgumentException(“display must not be null”);
    }
    if (!(params instanceof WindowManager.LayoutParams)) {
    throw new IllegalArgumentException(“Params must be WindowManager.LayoutParams”);
    }

final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
/**

  • 1. 根据 WindowManager.LayoutParams 的参数来对添加的子窗口进行相应的调整
    */
    parentWindow.adjustLayoutParamsForSubWindow(wparams);
    } else {

    }

ViewRootImpl root;
View panelParentView = null;

synchronized (mLock) {

/**

  • 2. 实例化 ViewRootImpl 对象,并赋值给 root 变量
    */
    root = new ViewRootImpl(view.getContext(), display);

view.setLayoutParams(wparams);

/**

  • 3. 添加 view 到 mViews 列表中
    /
    mViews.add(view);
    /
    *
  • 4. 将 root 存储在 ViewRootImp 列表中
    /
    mRoots.add(root);
    /
    *
  • 5. 将窗口的参数保存到布局参数列表中。
    */
    mParams.add(wparams);

// do this last because it fires off messages to start doing things
try {
/**

  • 6. 将窗口和窗口的参数通过 setView 方法设置到 ViewRootImpl 中
    */
    root.setView(view, wparams, panelParentView);
    } catch (RuntimeException e) {
    // BadTokenException or InvalidDisplayException, clean up.
    if (index >= 0) {
    removeViewLocked(index, true);
    }
    throw e;
    }
    }
    }
    复制代码

我们直接注释 6 ViewRootImpl#setView 方法,代码如下:

//ViewRootImpl.java
/**

  • We have one child
    */
    public void setView(View view, WindowManager.LayoutParams attrs, View panelParentView) {

//1. 请求刷新布局
requestLayout();

}
复制代码

该类 setView 代码比较多,我们直接直接找我们需要的核心代码注释 1 ,我们看它内部实现,代码如下:

//ViewRootImpl.java
@Override
public void requestLayout() {
//如果 onMeasure 和 onLayout 工作还没完成,那么就不允许调用 执行
if (!mHandlingLayoutInLayoutRequest) {
//检查线程,是否是主线程
checkThread();
mLayoutRequested = true;
//开始遍历
scheduleTraversals();
}
}
复制代码

上面代码首先是检查是否可以开始进入绘制流程,我们看 checkThread 方法实现,代码如下:

//ViewRootImpl.java
/**

  • 检查当前线程,如果是子线程就抛出异常。
    */
    void checkThread() {
    if (mThread != Thread.currentThread()) {
    throw new CalledFromWrongThreadException(
    “Only the original thread that created a view hierarchy can touch its views.”);
    }
    }
    复制代码

是不是在程序中这个异常经常遇见?现在知道它是从哪里抛出来的了吧,下面我们接着看 scheduleTraversals 方法的实现,代码如下:

//ViewRootImpl.java
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
/**

  • mChoreographer:用于接收显示系统的 VSync 信号,在下一帧渲染时控制执行一些操作,
  • 用于发起添加回调 在 mTraversalRunnable 的 run 中具体实现
    */
    mChoreographer.postCallback(
    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
    if (!mUnbufferedInputDispatch) {
    scheduleConsumeBatchedInput();
    }
    notifyRendererOfFramePending();
    pokeDrawLockIfNeeded();
    }
    }
    复制代码

这里代码的意思就是如果收到系统 VSYNC 信号,那么就会在 mTraversalRunnable run 方法执行,代码如下:

//ViewRootImpl.java
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
//1. 删除当前接收 SYNCBarrier 信号的回调
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);

if (mProfile) {
Debug.startMethodTracing(“ViewAncestor”);
}
//2. 绘制入口
performTraversals();

if (mProfile) {
Debug.stopMethodTracing();
mProfile = false;
}
}
}
复制代码

在文章的开始部分我们知道 performTraversals 就是测量 layout draw 入口,那么我们继续看它的实现,代码如下:

//ViewRootImpl.java
private void performTraversals() {

int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);

//1. 执行测量流程
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);

//2. 执行布局流程
performLayout(lp, desiredWindowWidth, desiredWindowHeight);

//3. 执行绘制流程
performDraw();
}

//说明 1.
private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
if (mView == null) {
return;
}
Trace.traceBegin(Trace.TRACE_TAG_VIEW, “measure”);
try {
//调用 View 的 measure 方法
mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}

//说明 2.
private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth, int desiredWindowHeight) {

final View host = mView;//代表 DecorView

//内部在调用 View onLayout
host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());

}

//说明 3.
private void performDraw() {

//内部通过调用 GPU/CPU 来绘制
draw(fullRedrawNeeded);

}
复制代码

到这里上面对应的函数会对应调用 View 的 onMeasure -> onLayout -> ondraw 方法,下面我们就具体来说明下绘制过程。

measure 过程

measure 过程要分情况来看,如果一个原始的 View ,那么通过 measure 方法就完成了其测量过程,如果是一个 ViewGroup ,除了完成自己的测量过程外,还会遍历它所有的子 View 的 measure 方法,各个子元素在递归去执行这个流程(有子 View 的情况),下面针对这两种情况分别讨论。

View 的 measure 过程

//View.java
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
boolean optical = isLayoutModeOptical(this);

onMeasure(widthMeasureSpec, heightMeasureSpec);


}
复制代码

View 的 measure 过程由其 measure 方法来完成,通过 View#measure 源码可以知道 它是被 final 修饰的,那么就代表了子类不能重写,通过上面源码我们知道在 View#measure 内部又会去调用 onMeasure 方法,我们接着看它的源码实现,代码如下:

//View.java
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
复制代码

上面的代码就做了一件事儿,就是设置测量之后的宽高值,我们先来看看 getDefaultSize 方法,代码如下:

//View.java
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
//根据 measureSpec 拿到当前 View 的 specMode
int specMode = MeasureSpec.getMode(measureSpec);
//根据 measureSpec 拿到当前 View 的 specSize
int specSize = MeasureSpec.getSize(measureSpec);

switch (specMode) {
case MeasureSpec.UNSPECIFIED: //一般用于系统内部的测量过程
result = size;//直接返回传递进来的 size
break;
case MeasureSpec.AT_MOST:// wrap_content 模式,大小由子类决定但是不能超过父类
case MeasureSpec.EXACTLY://精准模式
result = specSize;//返回测量之后的大小
break;
}
return result;
}
复制代码

通过上面代码可以看出,getDefaultSize 内部逻辑不多,也比较简单,对于我们来说只需要关心 AT_MOST, EXACTLY 这两种情况就行,其最终就是返回测量之后的大小。这里要注意的是这里测量之后的大小并不是最终 View 的大小,最终大小是在 layout 阶段确定的,所以这里一定要注意。

我们来看一下 getSuggestedMinimumXXXX() 源码实现:

//View.java
protected int getSuggestedMinimumHeight() {
return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

}
protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
复制代码

可以看到 getSuggestedMinimumXXX 内部的代码意思就是,如果 View 没有设置背景,那么返回 android:minWidth 这个属性所指定的值,这个值可以为 0,如果 View 设置了背景,则返回 android:minWidth 和背景的 最小宽度/最小高度 这两者的者中的最大值,它们返回的就是 UNSPECIFIED 情况下的宽高。

从 getDefaultSize 方法实现来看, View 的宽高由 specSize 决定,所以我们可以得到如下结论:既然 measure 被 final 修饰不能重写,可是我们在它内部也发现了新大陆 onMeasure 方法,我们可以直接继承 View 然后重写 onMeasure 方法并设置自身大小。

这里在重写 onMeasure 方法的时候设置自身宽高需要注意一下,如果在 View 在布局中使用 wrap_content ,那么它的 specMode 是 AT_MOST 模式,在这种模式下,它的宽高等于 specSize,也就是父类控件空剩余可以使用的空间大小,这种效果和在布局中使用 match_parent 完全一致,那么如何解决这个问题勒,可以参考下面代码:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)

val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)

/**

  • 说明在布局中使用了 wrap_content 模式
    */
    if (widthMeasureSpec == MeasureSpec.AT_MOST && heightMeasureSpec == MeasureSpec.AT_MOST){
    setMeasuredDimension(mWidth,mHeight)
    }else if (widthMeasureSpec == MeasureSpec.AT_MOST){
    setMeasuredDimension(mWidth,heightSize)
    }else if (heightMeasureSpec == MeasureSpec.AT_MOST){
    setMeasuredDimension(widthSize,mHeight)
    }else {
    setMeasuredDimension(widthSize,heightSize)
    }
    }
    复制代码

在上面代码中,我们只需要给 View 指定一个默认的内部宽高(mWidth、mHeight),并在 wrap_content 的时候设置此宽高即可。对于非 wrap_content 情形,我们就沿用系统的测量值即可。

ViewGroup 的 measure 过程

对于 ViewGroup 来说,初了完成自己的 measure 过程以外,还会去遍历调用所有的子 View 的 measure 方法,各个元素递归去执行这个过程,和 View 不同的是 ViewGroup 是一个抽象类,因此它没有重写 View 的 onMeasure 方法,但是它定义了一个 measureChild 方法,代码如下:

//ViewGroup.java
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
final int size = mChildrenCount;
//拿到所有子 View
final View[] children = mChildren;
//遍历子 View
for (int i = 0; i < size; ++i) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
//依次对子 View 进行测量
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
}
复制代码

上面代码也很简单,ViewGroup 在 measure 时,会对每一个子元素进行 measure ,如下代码所示:

//ViewGroup.java
protected void measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec) {
//拿到该子 View 在布局XML中或者代码中定义的属性
final LayoutParams lp = child.getLayoutParams();
//通过 getChildMeasureSpec 方法,根据父元素的宽高测量规则拿到子元素的测量规则
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom, lp.height);
//拿到子元素的测量规则之后传递到 View 中,开始 measure 流程
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
复制代码

measureChild 代码逻辑也很容易理解,首先取出设置的 LayoutParams 参数,然后通过 getChildMeasureSpec 方法,根据父元素的宽高测量规格拿到子元素的测量规格,最后将拿到的测量规格直接传递给 View#measure 来进行测量。

measure 小总结:

  1. 获取 View 最终宽高,需要在 onLayout 中获取,因为 measure 在某些极端的情况下需要测量多次。
  2. 在 Activity 中获取 View 的宽高需要使用 Activity/View#onWindowFocusChangedview.post(runnable)ViewTreeObserver 的 onGlobalLayoutListener 回调手动调用 view.measure(int width,int height) ,最终使用哪个以实际情况来定。

layout 过程

measure 完之后就是 layout 确定子 View 的位置,当 ViewGroup 位置确定以后,它在 onLayout 中会遍历所有的子元素并调用其 layout 方法,在 layout 方法中 onLayout 方法又会被调用,我们直接看 View 的 layout 方法,代码如下:

//View.java
@SuppressWarnings({“unchecked”})
public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}

int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;

/**

  • 1. 通过 setFrame 来初始化四个点的位置
    */
    boolean changed = isLayoutModeOptical(mParent) ?
    setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);

if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
/**

  • 2. 确定 子View 位置
    */
    onLayout(changed, l, t, r, b);


}
}
复制代码

layout 方法大致流程首先会通过 setFrame 方法来设定 View 四个顶点位置,View 的四个顶点一旦确认了那么就会接着调用 onLayout 方法,这个方法的用途是父容器确定子元素的位置。

draw 过程

measure 和 layout 过程确定了之后就该执行绘制的最后一个流程了 draw,它的作用就是将 View 绘制到屏幕上面,View 的绘制过程遵循以下几点:

  1. 绘制背景 backgroud.draw(canvas)
  2. 绘制自己(onDraw)
  3. 绘制 children (dispatchDraw)
  4. 绘制装饰 (onDrawScrollBars)

下面我们从源码的角度来看一下 draw 实现,代码如下:

//ViewRootImpl.java
private void performDraw() {

draw(fullRefrawNeeded);

}

private void draw(boolean fullRedrawNeeded) {

//使用硬件加速绘制,mView -> DecorView
mAttachInfo.mThreadedRenderer.draw(mView, mAttachInfo, this);

//使用 CPU 绘制
if (!drawSoftware(surface, mAttachInfo, xOffset, yOffset, scalingRequired, dirty)) {
return;
}
}
复制代码

这里我们直接看 CPU 绘制

//ViewRootImpl.java
private boolean drawSoftware(Surface surface, AttachInfo attachInfo, int xoff, int yoff,
boolean scalingRequired, Rect dirty) {

mView.draw(canvas);

}

复制代码

上面代码内部会调用 View#draw 方法,我们直接看内部实现,代码如下:

//View.java
public void draw(Canvas canvas) {
final int privateFlags = mPrivateFlags;
final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
(mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;

int saveCount;

if (!dirtyOpaque) {
/**

  • 1. 绘制背景
    */
    drawBackground(canvas);
    }

final int viewFlags = mViewFlags;
boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
if (!verticalEdges && !horizontalEdges) {
/**

  • 2. 调用 ondraw 绘制 View 内容
    */
    if (!dirtyOpaque) onDraw(canvas);

/**
*3. 绘制 View 的子 View
*/
dispatchDraw(canvas);

drawAutofilledHighlight(canvas);

if (mOverlay != null && !mOverlay.isEmpty()) {
mOverlay.getOverlayView().dispatchDraw(canvas);
}

/**

  • 4. 绘制 View 的装饰
    */
    onDrawForeground(canvas);

drawDefaultFocusHighlight(canvas);

if (debugDraw()) {
debugDrawFocus(canvas);
}

// we’re done…
return;
}


}
复制代码

到目前为止,View 的绘制流程就介绍完了。根节点是 DecorView,整个 View 体系就是一棵以 DecorView 为根的View 树,依次通过遍历来完成 measure、layout 和 draw 过程。而如果要自定义 view ,一般都是通过重写onMeasure(),onLayout(),onDraw() 来完成要自定义的部分,整个绘制流程也基本上是围绕着这几个核心的地方来展开的。

自定义 View

下面我们将详细介绍自定义 View 。自定义 View 的作用不用多说,这个大家应该都比较清楚,如果你想做出比较绚丽华彩的 UI 那么仅仅依靠系统的控件是远远不够的,这个时候就必须通过自定义 View 来实现这个绚丽的效果。自定义 View 是一个综合性技术体系,它涉及 View 的层次结构、事件分发、和 View 工作原理等,这些技术每一项又都是初学者难以掌握的,所以前面 2 篇文章我们分别讲解了 View 基础,事件分发以及该篇文章的 View 工作原理等知识,有了这些知识之后再来学习自定义 View 那将面对复杂的 UI 效果也能一一应对了,下面我们就来认识自定义 View 在该小节末尾也会给出实际例子,以供大家参考。

自定义 View 分类

自定义 View 的分类标准不唯一,这里则把它分为四大类,请看下面:

  1. 继承 View 重写 onDraw 方法

这个方法主要用于实现一些不规则的效果,即这种效果不方便通过布局的组合方式达到,往往需要静态或者动态地显示一些不规则的图形。很显然这需要通过绘制的方式实现,即重写 onDraw 方法,采用这种方式需要自己支持 wrap_content ,并且 padding 也需要自己处理。

  1. 继承 ViewGroup 派生特殊的 Layout

这种方式主要用于实现自定义的布局,即除了基本系统布局之外,我们重新定义一种新的布局,当某种效果看起来像几种 View 组合在一起的时候,可以采用这种方式来实现。采用这种方式稍微复杂一些,需要合适的处理 ViewGroup 的 measure 、布局这两个过程,并同时处理子元素的测量和布局过程。

  1. 继承特定的 View (比如 TextView)

这种方式比较常见,一般是用于扩展某种已有的 View 的功能,比如 TextView ,这种方法比较容易实现。也不需要自己支持 wrap_content 和 padding 等。

  1. 继承特定的 ViewGroup(比如 LinearLayout)

这种方式也比较常见,当某种效果看起来很像几种 View 组合在一起的时候,可以采用这种方法来实现。采用这种方法不需要自己处理 ViewGroup 的测量和布局这 两个过程,需要注意的这种方法和方法 2 的区别,一般来说方法 2 能实现的效果方式 4 也能实现,两则的区别在于方法 2 更接近 View 的底层。

自定义 View 注意事项

该小节主要介绍 自定义 View 过程中的一些注意事项,这些问题如果处理不好有可能直接导致 View 的正常使用,具体事项如下:

  1. 让 View 支持 wrap_content

这是因为直接继承 View 或者 ViewGroup 的控件,如果不在 onMeasure 中对 wrap_content 做特殊处理,那么当外界在布局中使用 wrap_content 属性时就无法达到预期的效果,具体处理可以参考该篇文章的 View 的 measure 过程

  1. 如果有必要,让你的 View 支持 padding

这是因为直接继承 View 的控件,如果不在 draw 方法中处理 padding ,那么 padding 属性时无法起作用的。另外直接继承 ViewGroup 的控件需要在 onMeasure 和 onLayout 中考虑 padding 和子元素的 margin 对其造成的影响,不然将导致 padding 和 子元素的 margin 失效。

  1. 尽量不要在 View 中使用 Handler

这是因为 View 内部本身就提供了 post 系列的方法,完全可以替代 Handler 的作用,当然除非你很明确需要使用 handler 来发送消息。

  1. View 中如果有线程或者动画,需要及时停止,参考 View#onDetachedFromWindow

这一条也很好理解,如果有线程或者动画需要停止时,那么 onDetachedFromWindow 是一个很好的时机。当包含此 View 的 Activity 退出或者当前 View 被 remove 时,View 的 onDetachedFromWindow 方法会被调用,和此方法对应的是 onAttachedToWindow ,当包含此 View 的 Activity 启动时,View 的 onAttachedToWindow 方法会被调用,同时,当 View 变得不可见时我们也需要停止线程和动画,如果不及时处理这种问题,将有可能会造成内存泄漏。

  1. View 带有滑动嵌套情形时,需要处理好滑动冲突

如果有滑动冲突的话,那么就需要合适的处理滑动冲突,否则将严重影响 View 的效果,具体处理请看高级 UI 成长之路 (二) 深入理解 Android 8.0 View 触摸事件分发机制

自定义 View 示例

下面通过示例代码来一起学习自定义 View, 下面还是以自定义分类来具体体现。

  1. 继承 View 重写 onDraw 方法

为了更好的展示一些平时不容易注意到的问题,这里先实现一个很简单的自定义控件,我们先绘制一个圆,尽管如此,需要注意的细节还是很多的,为了实现一个规范控件,在实现过程必须考虑 wrap_content 模式以及 padding ,同时为了便捷性,还要对外提供自定义属性,我们先来看一下代码实现,如下:

class CircleView2: View {
val color = Color.RED
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
constructor(context: Context) : super(context) {
init()
}
constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
init()
}
constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
init()
}

override fun draw(canvas: Canvas) {
super.draw(canvas)
val height = height
val width = width
val radius = Math.min(width, height) / 2f
canvas.drawCircle(width/2f,height/2f,radius,paint)
}
private fun init() {
paint.setColor(color)
paint.isAntiAlias = true
}
}
复制代码

<?xml version="1.0" encoding="utf-8"?>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:layout_height=“100dp”/>

复制代码

上面简单绘制了一个以当前宽高的一半的最小值在自己的中心点绘制一个红色的实心圆,其实上面并不是一个规范的自定义控件为什么这么说呢?我们通过调整布局参数再来看一下

<?xml version="1.0" encoding="utf-8"?>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#000”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#9C27B0”
android:layout_margin=“20dp”
android:layout_height=“100dp”/>

复制代码

运行效果如上,可以看到 margin 属性是有效果的,这是因为 margin 属性是由父容器控制的,因此不需要再 CircleView2 中做特殊处理。我们现在在来调整它的布局参数,为其设置 20dp 的 padding,如下所示:

<?xml version="1.0" encoding="utf-8"?>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#000”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#9C27B0”
android:layout_margin=“20dp”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#2196F3”
android:layout_margin=“20dp”
android:padding=“20dp”
android:layout_height=“100dp”/>

复制代码

运行效果如下:

可以看到 第三个圆 我们在布局中设置了 padding 结果根本没有无效,这就是我们在前面提到的直接继承自 View 和 ViewGroup 的控件,padding 是默认无法生效的,需要自己处理,我们在将其宽度设置为 wrap_content ,如下:

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“wrap_content”
android:background=“#8BC34A”
android:layout_margin=“20dp”
android:padding=“20dp”
android:layout_height=“100dp”/>
复制代码

运行效果如下:

结果发现 wrap_content 并没有达到预期的效果,对比图上其它的 MATCH_PARENT 属性绘制的圆其实跟 wrap_content 一样,其实的确是这样,这一点在源码中也讲解到了,可以看下面:

public static int getDefaultSize(int size, int measureSpec) {
int result = size;
//根据 measureSpec 拿到当前 View 的 specMode
int specMode = MeasureSpec.getMode(measureSpec);
//根据 measureSpec 拿到当前 View 的 specSize
int specSize = MeasureSpec.getSize(measureSpec);

switch (specMode) {
case MeasureSpec.UNSPECIFIED: //一般用于系统内部的测量过程
result = size;
break;
case MeasureSpec.AT_MOST:// wrap_content 模式,大小由子类决定但是不能超过父类
case MeasureSpec.EXACTLY://精准模式
result = specSize;
break;
}
return result;
}
复制代码

其实不管是 AT_MOST 或者 EXACTLY 都是按照 specSize 赋值,大小都是一样的,所以为了解决这个问题,我们需要重写 onMeasure 并且在 draw 方法中拿到 padding 然后减去该值 ,先来看代码实现:

/**

  • 解决 wrap_content
    */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val widthSpecMode = View.MeasureSpec.getMode(widthMeasureSpec)
    val widthSpecSize = View.MeasureSpec.getSize(widthMeasureSpec)
    val heightSpecMode = View.MeasureSpec.getMode(heightMeasureSpec)
    val heightSpecSize = View.MeasureSpec.getSize(heightMeasureSpec)
    if (widthSpecMode == View.MeasureSpec.AT_MOST && heightSpecMode == View.MeasureSpec.AT_MOST) {
    setMeasuredDimension(200, 200)
    } else if (widthSpecMode == View.MeasureSpec.AT_MOST) {
    setMeasuredDimension(200, heightSpecSize)
    } else if (heightSpecMode == View.MeasureSpec.AT_MOST) {
    setMeasuredDimension(widthSpecSize, 200)
    }
    }

/**

  • 解决 padding
    */
    override fun draw(canvas: Canvas) {
    super.draw(canvas)

val paddingLeft = paddingLeft
val paddingRight = paddingRight
val paddingBottom = paddingBottom
val paddingTop = paddingTop
val height = height - paddingBottom - paddingTop
val width = width - paddingLeft - paddingRight
val radius = Math.min(width, height) / 2f

canvas.drawCircle(paddingLeft + width/2f,paddingTop + height/2f,radius,paint)

}

复制代码

<?xml version="1.0" encoding="utf-8"?>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#000”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#9C27B0”
android:layout_margin=“20dp”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView2
android:layout_width=“match_parent”
android:background=“#2196F3”
android:layout_margin=“20dp”
android:padding=“20dp”
android:layout_height=“100dp”/>

<com.devyk.customview.sample_1.CircleView3
android:layout_width=“wrap_content”
android:background=“#8BC34A”
android:layout_margin=“20dp”
android:padding=“30dp”
android:layout_height=“100dp”/>

复制代码

运行效果如下:

通过我们在 draw 中减去了 各自的 padding 解决了 padding 的问题,通过重写 onMeasure 对该 View 设置宽高,解决了 wrap_content 属性的效果。

最后为了我们的自定义控件的扩展性,我们需要给它实现自定义属性,步骤如下所示:

//1. 我们在 values 目录下创建一个 attrs 开头的文件夹,然后在创建一个 attrs_circle 的文件,

<?xml version="1.0" encoding="utf-8"?>

//2. 代码中进行解析属性值
private fun initTypedrray(context: Context, attrs: AttributeSet) {
//拿到自定义属性组
val obtainStyledAttributes = context.obtainStyledAttributes(attrs, R.styleable.CircleView)
color = obtainStyledAttributes.getColor(R.styleable.CircleView_circle_view_color, Color.RED)
obtainStyledAttributes.recycle()

}

//3. 布局中声明自定义属性的空间,在根布局中添加如下属性
xmlns:app=“http://schemas.android.com/apk/res-auto”

//4. 在自定义 View 中配置该属性值
<com.devyk.customview.sample_1.CircleView3
android:layout_width=“wrap_content”
android:background=“#8BC34A”
android:layout_margin=“20dp”
app:circle_view_color = “#3F51B5”
android:padding=“20dp”
android:layout_height=“100dp”/>

复制代码

运行效果如下:

自定义属性也配置完成了。这样做的好处是在不修改原始代码的情况下,可以让用户自定义颜色值,扩展性比较强。

  1. 继承 ViewGroup 派生特殊的 Layout

我们先看一下我们需要实现的效果->流式布局

  1. 定义用于装 x 轴 View,y 轴 height, 容器中所有子 View 的容器

/**

  • 定义一个装所有子 View 的容器
    /
    protected var mAllViews: MutableList<List> = ArrayList<List>()
    /
    *
  • 定义行高
    /
    protected var mLineHeight: MutableList = ArrayList()
    /
    *
  • 定义行宽
    /
    protected var mLineWidth: MutableList = ArrayList()
    /
    *
  • 当前行上的子 View 控件
    */
    protected var mLinViews: MutableList = ArrayList()
    复制代码
  1. 重写 View onMeasure 方法,测量每一个子 View 的宽高,并且计算 X 轴上每一个子 View 的宽度是否超出总的 width

/**

  • 1. 确定所有子 View 的宽高
    */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    //根据当前宽高的测量模式,拿到宽高和当前模式
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightsize = MeasureSpec.getSize(heightMeasureSpec)

//如果当前容器 XML 布局定义的 wrap_content 那么就需要自己解决实际测量高度
var width = 0
var height = 0

//当前行高/宽
var lineWidth = 0
var lineHeight = 0

//拿到所有子 View 总数
val allViewCount = childCount

//遍历进行对子 View 进行测量
for (child in 0…allViewCount -1 ){
//拿到当前 View
var childView = getChildAt(child)
//判断当前 view 是否隐藏状态
if (childView.visibility == View.GONE) {
//如果是最后一个,拿到当前行高
if (child == allViewCount - 1){
width = Math.max(lineWidth,width)
height += lineHeight
}
continue
}
//对 childView 进行测量
measureChild(childView,widthMeasureSpec,heightMeasureSpec)
//拿到当前子 View 布局参数
val marginLayoutParams = childView.layoutParams as MarginLayoutParams
//拿到测量之后的宽、高 + 设置的 margin
val childWidth = childView.measuredWidth + marginLayoutParams.leftMargin + marginLayoutParams.rightMargin
val childHeight = childView.measuredHeight + marginLayoutParams.topMargin + marginLayoutParams.bottomMargin

//说明已经放不下
if (lineWidth + childWidth > widthSize - paddingLeft - paddingRight){
//拿到当前行最大的宽值
width = Math.max(width,lineWidth)
//当前行的宽度
lineWidth = childWidth
//子 View 总高度
height += lineHeight
//当前行的高度
lineHeight = childHeight
}else{
//将子 View 的宽度累计相加
lineWidth += childWidth
//拿到当前行最大的高度
lineHeight = Math.max(lineHeight,childHeight)

}
}
//设置当前容器的宽高
setMeasuredDimension(
//判断是否是 match——parent 模式如果不是,那么就是 wrap_content 或者 精准 dp 模式,需要所有子 View 宽/高 相加
if (widthMode === MeasureSpec.EXACTLY) widthSize else width + paddingLeft + paddingRight,
if (heightMode === MeasureSpec.EXACTLY) heightsize else height + paddingTop + paddingBottom
)
}
复制代码

  1. 将测量好的子 View 开始放入 ViewGroup 中

/**

  • 2. 确定所有子 View 的位置
    */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    //清空容器里面的数据
    mAllViews.clear()
    mLineHeight.clear()
    mLineWidth.clear()
    mLinViews.clear()

//拿到控件的宽
var width = width
//当前行宽
var lineWidth = 0
//当前行高
var lineHeight = 0
//当前 childCount
val childCount = childCount
//遍历子 View
for (childIndex in 0…childCount-1){
var childView = getChildAt(childIndex)
if(childView.visibility == View.GONE)continue
val marginLayoutParams = childView.layoutParams as MarginLayoutParams
//拿到最后 View 真实宽高
val measuredWidth = childView.measuredWidth
val measuredHeight = childView.measuredHeight

//当前子 View 的宽+ 当前行宽再加当前 margin 如果大于当前总宽的话 说明放不下了,需要换行
if (measuredWidth + lineWidth + marginLayoutParams.leftMargin + marginLayoutParams.rightMargin > width - paddingRight-paddingLeft){
//当前行的最大的高
mLineHeight.add(lineHeight)
//当前行总宽度
mLineWidth.add(lineWidth)
//这里面装的是每一行所有的子View,该容器的 size 取决于 有多少行
mAllViews.add(mLinViews)
将测量好的子 View 开始放入 ViewGroup 中

/**

  • 2. 确定所有子 View 的位置
    */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    //清空容器里面的数据
    mAllViews.clear()
    mLineHeight.clear()
    mLineWidth.clear()
    mLinViews.clear()

//拿到控件的宽
var width = width
//当前行宽
var lineWidth = 0
//当前行高
var lineHeight = 0
//当前 childCount
val childCount = childCount
//遍历子 View
for (childIndex in 0…childCount-1){
var childView = getChildAt(childIndex)
if(childView.visibility == View.GONE)continue
val marginLayoutParams = childView.layoutParams as MarginLayoutParams
//拿到最后 View 真实宽高
val measuredWidth = childView.measuredWidth
val measuredHeight = childView.measuredHeight

//当前子 View 的宽+ 当前行宽再加当前 margin 如果大于当前总宽的话 说明放不下了,需要换行
if (measuredWidth + lineWidth + marginLayoutParams.leftMargin + marginLayoutParams.rightMargin > width - paddingRight-paddingLeft){
//当前行的最大的高
mLineHeight.add(lineHeight)
//当前行总宽度
mLineWidth.add(lineWidth)
//这里面装的是每一行所有的子View,该容器的 size 取决于 有多少行
mAllViews.add(mLinViews)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值