自定义一个 6 人的房间布局

最近项目有新需求,要求一个房间内有最多六个人同时在线,房间人数从 0 到 6 个变化有不同的动画效果,而且自己的视图永远在右上角,效果如下图


room
room

刚以看到这个需求动画的时候,觉得很麻烦,没法做呀,当时在想,这个需要知道不同人数所对应的坐标点,在 join 的时候,动态计算一下将要加入的 view 的坐标
当时也确实是这么做的,在 join 的代码写的差不多了,开始写 leave 相关的代码,发现 leave 很麻烦,因为不确定是哪一个位置的 view 要离开,所以目标状态也不确定
于是决定换个思路重新写,之前的方案行不通是因为一切都是动态计算的,在 leave 的时候,要离开的 view 不确定,导致目标状态也不确定,所以导致 leave 的代码没法写,最后想到一个比较好的方案
就是在 RoomLayout 初始化完成后,就确定下来一个布局模型集合,集合里固定了 0 - 6 个 view 所对应的所有坐标,这样在 join 和 leave 的时候,只需要从当前的 view 位置向一个确定的位置变化即可
多说无益,开始撸代码,按照自定义 Layout 的步骤开始写

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    measureChildren(widthMeasureSpec, heightMeasureSpec);
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

在测量阶段,不需要做什么特殊处理,只需要测量一下子 View 即可

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    halfW = getWidth() / 2;
    halfH = getHeight() / 2;
    thirdH = getHeight() / 3;
    mCompare.set(l, t, r, b);
    // 如果本次的 layout 与上一次存储的不一样,那么就重新确定坐标
    if (mBounds.isEmpty() || !mBounds.equals(mCompare)) {
        mBounds.set(l, t, r, b);
        prepareLayoutModels();
    }
    // 根据当前个数选定 布局模型 并对 INFLATE 布局
    selectLayoutModel();
}

在布局这里要确定下来不同 view 个数对应的每个 view 的位置

/**
* 布局模型,用来存储不同子 view 的个数对应的坐标点
*/
private static class LayoutModel {
    List<Rect> bounds = new LinkedList<>();
}
/**
 * 准备 布局模型
 */
private void prepareLayoutModels() {
    // 反向布局,最后一个 view 永远是自己
    // 1
    LayoutModel model1 = new LayoutModel();
    model1.bounds.add(new Rect(0, 0, getWidth(), getHeight()));
    // 2
    LayoutModel model2 = new LayoutModel();
    model2.bounds.add(new Rect(0, 0, getWidth(), getHeight())); // 0
    int left = getWidth() / 16 * 9;
    int bottom = (getWidth() - left) / 3 * 4;
    model2.bounds.add(new Rect(left, 0, getWidth(), bottom)); // 1 mine
    // ... 中间还有一些其他 view 个数的初始化
    // 6
    LayoutModel model6 = new LayoutModel();
    model6.bounds.add(new Rect(halfW, thirdH * 2, getWidth(), getHeight())); // 0
    model6.bounds.add(new Rect(0, thirdH * 2, halfW, getHeight())); // 1
    model6.bounds.add(new Rect(halfW, thirdH, getWidth(), thirdH * 2)); // 2
    model6.bounds.add(new Rect(0, thirdH, halfW, thirdH * 2)); // 3
    model6.bounds.add(new Rect(0, 0, halfW, thirdH)); // 4
    model6.bounds.add(new Rect(halfW, 0, getWidth(), thirdH)); // 5 mine
    // 把每个模型存储在 map 中
    mLayoutmodels.put(0, model1);
    mLayoutmodels.put(1, model2);
    mLayoutmodels.put(2, model3);
    mLayoutmodels.put(3, model4);
    mLayoutmodels.put(4, model5);
    mLayoutmodels.put(5, model6);
}

这里规定最后一个 view 是自己的 view,因为在房间内只有两个人的时候,也就是自己和另一个人,自己的 view 在右上角,第二个人的 view 铺满父布局,所以如果不反过来,就是导致自己的 view 被铺满的 view 盖住
初始化完布局模型后,开始布局

// 选定 布局模型
private void selectLayoutModel() {
    int N = getChildCount();
    if (N == 0 || N > mLayoutmodels.size()) {
        return;
    }
    LayoutModel layoutModel = mLayoutmodels.get(N - 1);
    for (int i = 0; i < N; ++i) {
        View child = getChildAt(i);
        // layoutModel 里面存储的是最终要展示的 view 坐标
        Rect end = layoutModel.bounds.get(i);
        ViewPropertyHolder holder = getHolder(child);
        holder.end.set(end);
        // 对 INFLATE 状态的 view 布局,然后设置为 NORMAL 状态
        if (holder.state == ViewPropertyHolder.INFLATE) {
            holder.state = ViewPropertyHolder.NORMAL;
            holder.start.set(end);
            child.layout(end.left, end.top, end.right, end.bottom);
        } else if (holder.state == ViewPropertyHolder.ADD) {
            // 对于 add 进来的 view 它会从不同的地方进来,所以要先布局在预定位置
            Rect start = holder.start;
            child.layout(start.left, start.top, start.right, start.bottom);
        }
    }
}
/**
 * 获取存储在 View 中的相关属性
 */
private ViewPropertyHolder getHolder(View child) {
    // HOLDER 是一个定义在 ids.xml 中的一个 id
    ViewPropertyHolder holder = (ViewPropertyHolder) child.getTag(HOLDER);
    if (holder == null) {
        holder = new ViewPropertyHolder();
        child.setTag(HOLDER, holder);
    }
    return holder;
}
// 存储 view 的属性的类
private static class ViewPropertyHolder {
    static final int ADD = 1; // 待添加
    static final int REMOVE = 2; // 待移除
    static final int NORMAL = 3; // 正常状态
    static final int INFLATE = 4; // 新添加并且不执行动画
    int state = INFLATE;
    // 开始坐标
    Rect start = new Rect();
    // 结束坐标
    Rect end = new Rect();
}

对子 view 布局相关的东西就写完了,接下来是动画部分,动画我使用的是不停的 layout 子 view 来实现的

/**
 * 加入一个 view
 *
 * @param view     view
 * @param needAnim 是否需要动画
 */
public void join(View view, boolean needAnim) {
    ViewPropertyHolder holder = getHolder(view);
    if (needAnim && (mIsAnimating || mPendingAnim.size() > 0) && mIsAttached) {
        holder.state = ViewPropertyHolder.ADD;
        mPendingAnim.add(view);
    } else if (needAnim && mIsAttached) {
        holder.state = ViewPropertyHolder.ADD;
        handleAddAndPrepareAnim(view);
    } else {
        holder.state = ViewPropertyHolder.INFLATE;
        addView(view, 0);
    }
}

/**
 * 移除 一个 view
 *
 * @param view view
 */
public void leave(View view) {
    ViewPropertyHolder holder = getHolder(view);
    if (mIsAnimating || mPendingAnim.size() > 0) {
        holder.state = ViewPropertyHolder.REMOVE;
        mPendingAnim.add(view);
    } else {
        holder.state = ViewPropertyHolder.REMOVE;
        handleRemoveAndPrepareAnim(view);
    }
}

上面的是加入和离开的代码,需要先判断是否正在动画,如果在动画,那么把目标加入一个 list 中,以备后用

private void handleAddAndPrepareAnim(View toAdd) {
    prepareViewStart(toAdd);
    addView(toAdd, 0);
    selectLayoutModel();
    startAnimate();
}

private void handleRemoveAndPrepareAnim(View toRemove) {
    prepareViewStart(null);
    removeView(toRemove);
    selectLayoutModel();
    startAnimate();
}

/**
 * 准备当前 view 的坐标点
 */
private void prepareViewStart(View add) {
    int N = getChildCount();
    for (int i = 0; i < N; ++i) {
        View child = getChildAt(i);
        ViewPropertyHolder holder = getHolder(child);
        holder.start.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
    }
    if (add == null) {
        return;
    }
    // 确定 新 add 进来的 view 的位置
    ViewPropertyHolder holder = getHolder(add);
    switch (N) {
        case 1:
            holder.start.set(-getWidth(), 0, 0, getHeight());
            break;
        case 2:
            holder.start.set(0, getHeight(), getWidth(), getHeight() + halfH);
            break;
        case 3:
            holder.start.set(getWidth(), halfH, getWidth() + halfW, getHeight());
            break;
        case 4:
            holder.start.set(0, getHeight(), halfW, getHeight() + thirdH);
            break;
        case 5:
            holder.start.set(halfW, getHeight(), getWidth(), getHeight() + thirdH);
            break;
    }
}

接下来就开始动画了

private void startAnimate() {
    ViewCompat.postOnAnimation(this, new Runnable() {
        @Override
        public void run() {
            animatChild();
        }
    });
}

private void animatChild() {
    if (!mIsAttached || mIsAnimating) {
        return;
    }
    int N = getChildCount();
    // 动画集合
    List<Animator> animators = new ArrayList<>();
    for (int i = 0; i < N; ++i) {
        View view = getChildAt(i);
        ViewPropertyHolder holder = getHolder(view);
        // 获取需要更新位置的属性值
        PropertyValuesHolder[] childValuesHolder = getChildValuesHolder(view);
        if (childValuesHolder != null) {
            ViewValueAnimator animator = ViewValueAnimator.ofPropertyValuesHolder(childValuesHolder);
            animator.holder = holder;
            animator.target = view;
            animator.addUpdateListener(new AnimatorUpdateListener());
            animator.addListener(new AnimatorAdapter());
            animators.add(animator);
        } else {
            Rect bound = holder.end;
            view.layout(bound.left, bound.top, bound.right, bound.bottom);
        }
    }
    if (animators.size() > 0) {
        mIsAnimating = true;
        mAnimatorSet.playTogether(animators);
        mAnimatorSet.setDuration(ANIM_DURATION);
        mAnimatorSet.setInterpolator(mInterpolator);
        if (mGlobalAnimListener == null) {
            mGlobalAnimListener = new GlobalAnimUpdateListener();
        }
        mAnimatorSet.addListener(mGlobalAnimListener);
        mAnimatorSet.start();
    }
}

开始动画的代码,要先确定哪些 view 位置需要变化,然后生成一个 ValueAnimator , 然后把所有的 ValueAnimator 一起开始动画

private static final String LEFT = "left";
private static final String TOP = "top";
private static final String RIGHT = "right";
private static final String BOTTOM = "bottom";

private PropertyValuesHolder[] getChildValuesHolder(View child) {
    ViewPropertyHolder holder = getHolder(child);
    if (holder.start.equals(holder.end)) { // 位置没有变化
        return null;
    }
    PropertyValuesHolder[] holders = new PropertyValuesHolder[4];
    holders[0] = PropertyValuesHolder.ofInt(LEFT, holder.start.left, holder.end.left);
    holders[1] = PropertyValuesHolder.ofInt(TOP, holder.start.top, holder.end.top);
    holders[2] = PropertyValuesHolder.ofInt(RIGHT, holder.start.right, holder.end.right);
    holders[3] = PropertyValuesHolder.ofInt(BOTTOM, holder.start.bottom, holder.end.bottom);
    return holders;
}

生成一个 PropertyValuesHolder 数组,指定两个坐标的 start 和 end 数值
下面是自定义的 ValueAnimator 和一些 Listeners

private static class AnimatorAdapter extends AnimatorListenerAdapter {
    @Override
    public void onAnimationEnd(Animator animation) {
        animation.removeAllListeners();
        ViewValueAnimator anim = (ViewValueAnimator) animation;
        anim.removeAllUpdateListeners();
        if (anim.holder != null) {
            anim.holder.state = ViewPropertyHolder.NORMAL;
        }
        anim.holder = null;
        anim.target = null;
    }
    @Override
    public void onAnimationCancel(android.animation.Animator animation) {
        onAnimationEnd(animation);
    }
}
private class GlobalAnimUpdateListener extends AnimatorListenerAdapter {
    @Override
    public void onAnimationStart(Animator animation) {
        mIsAnimating = true;
    }
    @Override
    public void onAnimationEnd(Animator animation) {
        animation.removeAllListeners();
        mIsAnimating = false;
        // 判断后续是否有继续开始动画的 view
        if (mPendingAnim.size() > 0) {
            View view = mPendingAnim.remove(0);
            ViewPropertyHolder holder = getHolder(view);
            if (holder.state == ViewPropertyHolder.ADD) {
                handleAddAndPrepareAnim(view);
            } else if (holder.state == ViewPropertyHolder.REMOVE) {
                handleRemoveAndPrepareAnim(view);
            }
        }
    }
    @Override
    public void onAnimationCancel(Animator animation) {
        onAnimationEnd(animation);
    }
}
private static class AnimatorUpdateListener implements ValueAnimator.AnimatorUpdateListener {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        ViewValueAnimator anim = (ViewValueAnimator) animation;
        int l = (int) anim.getAnimatedValue(LEFT);
        int t = (int) anim.getAnimatedValue(TOP);
        int r = (int) anim.getAnimatedValue(RIGHT);
        int b = (int) anim.getAnimatedValue(BOTTOM);
        // 不停的布局子 view
        anim.target.layout(l, t, r, b);
    }
}

/**
 * 持有 view 和 holder 的 ValueAnimator
 */
private static class ViewValueAnimator extends ValueAnimator {
    View target;
    ViewPropertyHolder holder;
    public static ViewValueAnimator ofPropertyValuesHolder(PropertyValuesHolder... values) {
        ViewValueAnimator anim = new ViewValueAnimator();
        anim.setValues(values);
        return anim;
    }
}

还有一些重写的函数

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();
    mIsAttached = true;
}

@Override
protected void onDetachedFromWindow() {
    super.onDetachedFromWindow();
    mIsAttached = false;
}

@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}

到这里,所有的代码基本都写完了,剩下一些变量声明什么的没有附上来
最后,本人才疏学浅,实现的可能不够完美,有任何意见或建议欢迎交流学习

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容