Android 实用布局

英文名中文名
FlowLayout流式布局
NineGridLayout九宫格布局
BoundLayout回弹布局
RefreshLayout下拉刷新布局

贩剑

Q: Github 一堆相应的UI组件库,为什么要重复造轮子?

A: 别人的劳斯莱斯轮子未必适合我这破单车。另外,做自己力所能及的事情,多练习也有好处。

基础

ViewGroup 作为容器类,基本上布局都继承该类或其子类,重写 onMeasureonLayout 方法进行自定义布局。其中官方早已实现五大布局: FrameLayoutLinearLayoutRelativeLayoutTableLayoutAbsoluteLayout 。随后, support 库也出了不少优秀布局,如 ConstraintLayoutCoordinatorLayout 等。以上都是官方叼炸天的布局,下面说说作为一个平民,我能做到的布局,由易到难。

样例

国际惯例,先上个 Sample ,让大爷们玩玩。

下载: LayoutSample.apk

github: https://github.com/4ndroidev/LayoutSample

FlowLayout

描述:相对简单,只需要关注满行后换行操作。 onMeasureonLayout 都是一个 for 循环的操作。另外使用 ListAdapter 实现子视图适配相对好,不局限于仅适合文本视图,其次在 RecyclerView 中使用时, ViewHolder 的重用也可以达到子视图重用。由于比较简单,直接贴代码。

public class FlowLayoutextends ViewGroup{

    private final static int DEFAULT_VERTICAL_SPACE = 10;
    private final static int DEFAULT_HORIZONTAL_SPACE = 6;

    private ListAdapter mAdapter;
    private DataSetObserver mObserver = new DataSetObserver() {
        @Override
        public void onChanged(){
            startUpdate();
        }
    };
    private int mVerticalSpace;
    private int mHorizontalSpace;
    private int mRowHeight;
    private OnItemClickListener mOnItemClickListener;

    public FlowLayout(Context context){
        this(context, null);
    }

    public FlowLayout(Context context, AttributeSet attrs){
        this(context, attrs, 0);
    }

    public FlowLayout(Context context, AttributeSet attrs,int defStyleAttr){
        super(context, attrs, defStyleAttr);
        if (attrs != null) {
            TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
            mVerticalSpace = array.getDimensionPixelOffset(R.styleable.FlowLayout_verticalSpace, DEFAULT_VERTICAL_SPACE);
            mHorizontalSpace = array.getDimensionPixelOffset(R.styleable.FlowLayout_horizontalSpace, DEFAULT_HORIZONTAL_SPACE);
            array.recycle();
        }
    }

    public void setAdapter(ListAdapter adapter){
        if (mAdapter != null) {
            mAdapter.unregisterDataSetObserver(mObserver);
        }
        mAdapter = adapter;
        if (mAdapter != null) {
            mAdapter.registerDataSetObserver(mObserver);
        }
        startUpdate();
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
        int measureWidth = getDefaultSize(0, widthMeasureSpec);
        int paddingHorizontal = getPaddingLeft() + getPaddingRight();
        int paddingVertical = getPaddingTop() + getPaddingBottom();
        int actualWidth = measureWidth - paddingHorizontal;
        int count = getChildCount();
        int freeWidth = actualWidth;
        int row = 0;
        for (int i = 0; i  freeWidth) {
                freeWidth = actualWidth - childWidth;
                row++;
            } else if (childWidth + space == freeWidth) {
                freeWidth = actualWidth;
                row++;
            } else {
                freeWidth -= childWidth + space;
            }
        }
        if (freeWidth < actualWidth) row++;
        setMeasuredDimension(measureWidth, paddingVertical + row * mRowHeight + Math.max(0, row - 1) * mVerticalSpace);
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b){
        int paddingLeft = getPaddingLeft();
        int paddingRight = getPaddingRight();
        int paddingTop = getPaddingTop();
        int row = 0;
        int actualWidth = getMeasuredWidth() - paddingLeft - paddingRight;
        int freeWidth = actualWidth;
        for (int i = 0, count = getChildCount(); i  freeWidth) {
                freeWidth = actualWidth - childWidth;
                row++;
                left = paddingLeft;
                top = paddingTop + row * mRowHeight + row * mVerticalSpace;
            } else if (childWidth + space == freeWidth) {
                left = actualWidth - freeWidth + space;
                top = paddingTop + row * mRowHeight + row * mVerticalSpace;
                freeWidth = actualWidth;
                row++;
            } else {
                left = actualWidth - freeWidth + space;
                top = paddingTop + row * mRowHeight + row * mVerticalSpace;
                freeWidth -= childWidth + space;
            }
            child.layout(left, top, left + childWidth, top + childHeight);
        }
    }

    @Override
    public void onViewRemoved(View child){
        super.onViewRemoved(child);
        child.setOnClickListener(null);
    }

    @Override
    public void onViewAdded(View child){
        super.onViewAdded(child);
        child.setOnClickListener(v -> {
            if (mOnItemClickListener != null) {
                int position = indexOfChild(child);
                mOnItemClickListener.onItemClick(this, child, position, mAdapter.getItemId(position));
            }
        });
    }

    public void setOnItemClickListener(OnItemClickListener onItemClickListener){
        mOnItemClickListener = onItemClickListener;
    }

    private void startUpdate(){
        if (mAdapter == null) {
            removeAllViews();
            return;
        }
        int expectCount = mAdapter.getCount();
        int childCount = getChildCount();
        if (childCount > expectCount) {
            for (int i = childCount - 1; i >= expectCount; i--) {
                removeViewAt(i);
            }
            for (int i = 0; i < expectCount; i++) {
                mAdapter.getView(i, getChildAt(i), this);
            }
        } else {
            for (int i = 0; i < childCount; i++) {
                mAdapter.getView(i, getChildAt(i), this);
            }
            for (int i = childCount; i < expectCount; i++) {
                addView(mAdapter.getView(i, null, this));
            }
        }
        requestLayout();
    }

    public interface OnItemClickListener{
        void onItemClick(FlowLayout parent, View view,int position, long id);
    }

}

NineGridLayout

描述:相对简单,根据容器大小,分成三栏;根据子视图数目布局,特殊地,当子视图数目为一时,视图占两行两列;当子视图数目为四时,分布为两行两列;其余按照三个一排即可。同样适合使用 ListAdapter 作适配,上代码。

public class NineGridLayoutextends ViewGroup{

    private final static int DEFAULT_DIVIDER_WIDTH = 6;

    private int mDividerWidth;
    private ListAdapter mAdapter;
    private DataSetObserver mObserver = new DataSetObserver() {
        @Override
        public void onChanged(){
            startUpdate();
        }
    };
    private OnItemClickListener mOnItemClickListener;

    public NineGridLayout(Context context){
        this(context, null);
    }

    public NineGridLayout(Context context, AttributeSet attrs){
        this(context, attrs, 0);
    }

    public NineGridLayout(Context context, AttributeSet attrs,int defStyleAttr){
        super(context, attrs, defStyleAttr);
        if (attrs != null) {
            TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.NineGridLayout);
            mDividerWidth = array.getDimensionPixelOffset(R.styleable.NineGridLayout_dividerWidth, DEFAULT_DIVIDER_WIDTH);
            array.recycle();
        }
    }

    /**
* 根据父组件大小,决定子组件大小
*/
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
        int width = getDefaultSize(0, widthMeasureSpec);
        int itemSize = (int) ((width - getPaddingLeft() - getPaddingRight() - 2 * mDividerWidth) / 3.0f);
        if (itemSize < 0)
            throw new IllegalStateException("measureWith must more than the sum of padding and dividerWidth!");
        int height = getPaddingTop() + getPaddingBottom();
        int count = getChildCount();
        if (count == 1) {
            height += 2 * itemSize + mDividerWidth;
        } else if (count <= 2="" 3)="" {="" height="" +="itemSize;" }="" else="" if="" (count=""  {
            if (mOnItemClickListener != null) {
                int position = indexOfChild(child);
                mOnItemClickListener.onItemClick(this, child, position, mAdapter.getItemId(position));
            }
        });
    }

    public void setOnItemClickListener(OnItemClickListener onItemClickListener){
        mOnItemClickListener = onItemClickListener;
    }

    public void setAdapter(ListAdapter adapter){
        if (mAdapter != null) {
            mAdapter.unregisterDataSetObserver(mObserver);
        }
        mAdapter = adapter;
        if (mAdapter != null) {
            mAdapter.registerDataSetObserver(mObserver);
        }
        startUpdate();
    }

    private void startUpdate(){
        if (mAdapter == null) {
            removeAllViews();
            return;
        }
        int expectCount = mAdapter.getCount();
        if (expectCount > 9) {
            throw new IllegalStateException("NineGridView can host at most 9 children!");
        }
        int childCount = getChildCount();
        if (childCount > expectCount) {
            for (int i = childCount - 1; i >= expectCount; i--) {
                removeViewAt(i);
            }
            for (int i = 0; i < expectCount; i++) {
                mAdapter.getView(i, getChildAt(i), this);
            }
        } else {
            for (int i = 0; i < childCount; i++) {
                mAdapter.getView(i, getChildAt(i), this);
            }
            for (int i = childCount; i < expectCount; i++) {
                addView(mAdapter.getView(i, null, this));
            }
        }
        requestLayout();
    }

    public interface OnItemClickListener{
        void onItemClick(NineGridLayout parent, View view,int position, long id);
    }

}

BoundLayout

描述:实现目前 Bilibili 番剧时间表的回弹布局,这种交互用作提示,感觉很友好。并可耻盗用了 Bilibili 两张图做 Demo 。相对复杂,上述两简单组件都没涉及手势,该组件涉及到手势处理。大致实现,就是当子视图不能横向或纵向滚动时,拦截手势,使用 ViewCompat.offsetLeftAndRightViewCompat.offsetTopAndBottom 进行横向或纵向偏移视图。代码有500来行,不适合贴全代码,仅上个较通用的手势代码,如果想看全代码,请到 github

github: BoundLayout

手势代码:

@Override
public boolean onInterceptTouchEvent(MotionEvent ev){
    if (!isEnabled()) return false;
    int action = ev.getActionMasked();
    int pointerIndex;
    switch (action) {

        case MotionEvent.ACTION_DOWN:
            mActivePointerId = ev.getPointerId(0);
            isBeingDragged = false;
            pointerIndex = ev.findPointerIndex(mActivePointerId);
            if (pointerIndex < 0) {
                return false;
            }
            mLastMotionX = ev.getX(pointerIndex);
            mLastMotionY = ev.getY(pointerIndex);
            break;

        case MotionEvent.ACTION_MOVE:
            if (mActivePointerId == MotionEvent.INVALID_POINTER_ID) {
                return false;
            }
            pointerIndex = ev.findPointerIndex(mActivePointerId);
            if (pointerIndex < 0) {
                return false;
            }
            startDragging(ev.getX(pointerIndex), ev.getY(pointerIndex));
            break;

        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
            isBeingDragged = false;
            mActivePointerId = MotionEvent.INVALID_POINTER_ID;
            break;
    }
    return isBeingDragged;
}

public boolean onTouchEvent(MotionEvent ev){
    if (!isEnabled()) return false;
    int action = ev.getActionMasked();
    int pointerIndex;
    switch (action) {

        case MotionEvent.ACTION_DOWN:
            mActivePointerId = ev.getPointerId(0);
            isBeingDragged = false;
            break;

        case MotionEvent.ACTION_MOVE: {
            pointerIndex = ev.findPointerIndex(mActivePointerId);
            if (pointerIndex < 0) {
                return false;
            }
            startDragging(ev.getX(pointerIndex), ev.getY(pointerIndex));
            float x = ev.getX(pointerIndex);
            float y = ev.getY(pointerIndex);
            float value = mOrientation == HORIZONTAL ? x : y;
            float lastValue = mOrientation == HORIZONTAL ? mLastMotionX : mLastMotionY;
            if (isBeingDragged) {
                int offset = (int) ((value - lastValue) / DRAGGING_RESISTANCE);
                if (mDirection == DIRECTION_POSITIVE && mContentOffset + offset  0) {
                    offset = -mContentOffset;
                }
                offsetChildren(offset);
                mLastMotionX = x;
                mLastMotionY = y;
            }
            break;
        }

        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            if (isBeingDragged) {
                isBeingDragged = false;
                animateOffsetToZero();
            }
            mActivePointerId = MotionEvent.INVALID_POINTER_ID;
            return false;

        case MotionEvent.ACTION_POINTER_DOWN: {
            pointerIndex = ev.getActionIndex();
            if (pointerIndex = 0; --i) {
            View child = viewGroup.getChildAt(i);
            if (x + scrollX >= child.getLeft() &&
                    x + scrollX = child.getTop() &&
                    y + scrollY < child.getBottom() &&
                    canScroll(child, x + scrollX - child.getLeft(), y + scrollY - child.getTop(), direction)) {
                return true;
            }
        }
    }
    return mOrientation == HORIZONTAL ? view.canScrollHorizontally(direction) : view.canScrollVertically(direction);
}

private void startDragging(float x, float y){
    if (isBeingDragged) return;
    if (mOrientation == HORIZONTAL) {
        startDraggingHorizontal(x, y);
    } else {
        startDraggingVertical(x, y);
    }
}

private void startDraggingHorizontal(float x, float y){
    float diffX = x - mLastMotionX;
    float diffY = y - mLastMotionY;
    if (Math.abs(diffX)  mTouchSlop && !canScroll(mContent, x, y, DIRECTION_NEGATIVE) ||
            diffX  0 ? mTouchSlop : -mTouchSlop);
        mDirection = diffX > 0 ? DIRECTION_POSITIVE : DIRECTION_NEGATIVE;
        isBeingDragged = true;
        requestDisallowInterceptTouchEvent(true);
    }
}

private void startDraggingVertical(float x, float y){
    float diffX = x - mLastMotionX;
    float diffY = y - mLastMotionY;
    if (Math.abs(diffX) > Math.abs(diffY)) return;
    if (diffY > mTouchSlop && !canScroll(mContent, x, y, DIRECTION_NEGATIVE) ||
            diffY  0 ? mTouchSlop : -mTouchSlop);
        mDirection = diffY > 0 ? DIRECTION_POSITIVE : DIRECTION_NEGATIVE;
        isBeingDragged = true;
        requestDisallowInterceptTouchEvent(true);
    }
}

private void offsetChildren(int offset){
    if (offset == 0) return;
    if (mOrientation == HORIZONTAL) {
        offsetHorizontal(offset);
    } else {
        offsetVertical(offset);
    }
}

private void offsetHorizontal(int offset){
    if (mHeader != null) {
        int displayMode = ((LayoutParams) mHeader.getLayoutParams()).getDisplayMode();
        if (displayMode == LayoutParams.DISPLAY_MODE_EDGE && mHeader.getLeft() <= 0="" 0)="" {="" if="" (mheader.getleft()="" +="" offset="" = getMeasuredWidth()) {
            if (mFooter.getRight() + offset >= getMeasuredWidth()) {
                mFooterOffset += offset;
                ViewCompat.offsetLeftAndRight(mFooter, offset);
            } else {
                mFooterOffset = 0;
                ViewCompat.offsetLeftAndRight(mFooter, getMeasuredWidth() - mFooter.getRight());
            }
        } else if (displayMode == LayoutParams.DISPLAY_MODE_SCROLL) {
            mFooterOffset += offset;
            ViewCompat.offsetLeftAndRight(mFooter, offset);
        }
    }
}

private void offsetVertical(int offset){
    if (mHeader != null) {
        int displayMode = ((LayoutParams) mHeader.getLayoutParams()).getDisplayMode();
        if (displayMode == LayoutParams.DISPLAY_MODE_EDGE && mHeader.getTop() <= 0="" 0)="" {="" if="" (mheader.gettop()="" +="" offset="" = getMeasuredHeight()) {
            if (mFooter.getBottom() + offset >= getMeasuredHeight()) {
                mFooterOffset += offset;
                ViewCompat.offsetTopAndBottom(mFooter, offset);
            } else {
                mFooterOffset = 0;
                ViewCompat.offsetTopAndBottom(mFooter, getMeasuredHeight() - mFooter.getBottom());
            }
        } else if (displayMode == LayoutParams.DISPLAY_MODE_SCROLL) {
            mFooterOffset += offset;
            ViewCompat.offsetTopAndBottom(mFooter, offset);
        }
    }
}

RefreshLayout

描述:下拉刷新,挺多人为这个东西搞到头大,官方的 SwipeRefreshLaout 虽然看上去很好,但是定制能力比较弱,不符合大众口味。众口难调,便出现了一些优秀库,热门的有 Ultra-Pull-To-Refresh:fire:SmartRefreshLayout 等。

然而我贩剑要自己实现,仅因为我想做一个 fling 效果自然的下拉刷新,另外嵌套滑动的出现令我意识到做下拉刷新相对容易了。

以下介绍的下拉刷新主要原理就是嵌套滑动,下拉刷新主要涉及的是头部的显示于隐藏,支持嵌套滑动的组件,在 onNestedScrollonNestedPreScroll 方法中进行头部的显示和隐藏。而不支持嵌套滑动的组件,可以通过分发手势时,进行类似嵌套滑动处理。另外处理 fling 时,向下滚动时,先隐藏头部,再进行内容滚动;向上滚动时,先内容滚动,如果时刷新状态,则滚动显示头部。

github: RefreshLayout

由于代码稍微有点多,仅贴嵌套滑动相关代码,其实该部分代码是参照 SwipeRefreshLayout 的。

@Override
public void onNestedScrollAccepted(View child, View target,int axes){
    mNestedScrollingParentHelper.onNestedScrollAccepted(child, target, axes);
    startNestedScroll(axes & ViewCompat.SCROLL_AXIS_VERTICAL);
    // set it to current offset
    // because maybe content view will get a touch down event while flinging hasn't been completed
    mTotalUnconsumed = mCurrentOffset;
    isNestedScrolling = true;
    isOffset = mCurrentOffset > 0;
}

@Override
public void onNestedPreScroll(View target,int dx, int dy, int[] consumed){
    if (dy > 0 && mTotalUnconsumed > 0) {
        // make header invisible
        offsetChildren(-Math.min(mTotalUnconsumed, dy));
        if (canRefresh && mRefreshHeader != null) {
            mRefreshHeader.onPull(mCurrentOffset >= mRefreshThreshold, mCurrentOffset);
        }
        if (dy > mTotalUnconsumed) {
            consumed[1] = dy - mTotalUnconsumed;
            mTotalUnconsumed = 0;
        } else {
            mTotalUnconsumed -= dy;
            consumed[1] = dy;
        }
    }
    final int[] parentConsumed = mParentScrollConsumed;
    if (dispatchNestedPreScroll(dx - consumed[0], dy - consumed[1], parentConsumed, null)) {
        consumed[0] += parentConsumed[0];
        consumed[1] += parentConsumed[1];
    }
}

@Override
public void onNestedScroll(View target,int dxConsumed, int dyConsumed, int dxUnconsumed, int dyUnconsumed){
    dispatchNestedScroll(dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed, mParentOffsetInWindow);
    int dy = dyUnconsumed + mParentOffsetInWindow[1];
    if (dy = mRefreshThreshold, mCurrentOffset);
        }
    }
}

@Override
public void onStopNestedScroll(View target){
    mNestedScrollingParentHelper.onStopNestedScroll(target);
    isNestedScrolling = false;
    isOffset = false;
    if (mCurrentOffset >= mRefreshThreshold) {
        if (canRefresh) {
            isRefreshing = true;
            canRefresh = false;
            if (mRefreshHeader != null) mRefreshHeader.onStart();
            if (mListener != null) mListener.onRefresh();
        }
        if (isRefreshing) animateOffsetToRefreshPosition();
        else animateOffsetToStartPosition();
    } else if (!isRefreshing) {
        if (mCurrentOffset > 0)
            animateOffsetToStartPosition();
        else {
            canRefresh = true;
        }
    }
    stopNestedScroll();
}

总结

根据自己需求定制组件,考虑通用性时,切勿过度定制。不要因为太简单而不做,不要因为太难而放弃。

极客头条稿源:极客头条 (源链) | 关于 | 阅读提示

本站遵循[CC BY-NC-SA 4.0]。如您有版权、意见投诉等问题,请通过eMail联系我们处理。
酷辣虫 » 移动开发 » Android 实用布局

喜欢 (0)or分享给?

专业 x 专注 x 聚合 x 分享 CC BY-NC-SA 4.0

使用声明 | 英豪名录