Android入门第64天-MVVM下瀑布流界面的完美实现-使用RecyclerView

news2024/9/8 23:40:47

前言

        网上充满着不完善的基于RecyclerView的瀑布流实现,要么根本是错的、要么就是只知其一不知其二、要么就是一充诉了一堆无用代码、要么用的是古老的MVC设计模式。

        一个真正的、用户体验类似于淘宝、抖音的瀑布流怎么实现目前基本为无解。因为本人正好自己空闲时也在做前后台一体化开发,所以直接从本人自己项目里(mao-sir.com)公开一部分核心代码以便于加快大家的学习进度,以使得各位不要在这种小东西上折腾太多时间。

什么是瀑布流

        注意看这边的这个布局,大家有没有发觉这些照片分成左右、最多的还有分成三列的,但是每一行的照片、视频、内容的高度是“错开”的。最早采用此布局的网站是Pinterest,逐渐在国内流行开来。国内大多数做的好的大厂的APP都是这种布局、尤以UGC(UGC它的中文意思是用户生产内容的意思,简称为UGC)为主的APP采用此布局最多像:知乎上的精品贴、推荐贴、小红书种草等都是这种风格。

瀑布流布局的优点为

  1. 吸引用户,当用户在浏览瀑布流式布局的时候(这里抛开懒加载),用户会产生一种错觉,就是信息是不停的在更新的,这会激发用户的好奇心,使用户不停的往下滑动。
  2. 良好视觉体验,采用瀑布流布局的方式可以打破常规网站布局排版,给用户眼前一亮的新鲜感,用户在浏览内容时会感到很有新鲜感,带来良好的视觉体验。
  3. 更好的适应移动端,由于移动设备屏幕比电脑小,一个屏幕显示的内容不会非常多,因此可能要经常翻页。而在建网站时使用瀑布流布局,用户则只需要进行滚动就能够不断浏览内容。(这一点和懒加载有一点像)

怎么实现瀑布流

        网上有一些第三方控件使用了瀑布流,但是这些第三方控件都已经废弃或者是停更了。这些第三方控件本人都用过,不是有各种BUG就是把问题搞了很复杂。这东西其实很简单,一天内就可以做出生产级别的应用了,哪有这么难。

        难就是难在太多初学者为了赶项目或者说很多人急功近利,只想着copy paste,因此搞了一堆其实无用的代码还把问题“混搅”了。

        因此本篇会从本质上还原最最干净的瀑布流,同时本人会基于mvvm设计模式来讲这个瀑布流。我们用的就是Android原生的控件:RecyclerView组件。下面是类淘宝、抖音用户极致体验的一个瀑布流“正例”(我们在文未还会给出一个体验不好的反例供读者对比)。

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
        </androidx.recyclerview.widget.RecyclerView>

下面我们来看实现 。

基于MVVM设计模式的RecyclerView实现瀑布流代码

工程整体结构

        这是一个使用androidx的基于mvvm的工程。

        至于如何把一个工程变成androidx和mvvm此处就不再赘述了,在我前面的博客中已经写了很详细了。

布局

activity_main.xml布局

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical"
        tools:context=".MainActivity">

        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/rv"
            android:layout_width="match_parent"
            android:layout_height="wrap_content">
        </androidx.recyclerview.widget.RecyclerView>
    </LinearLayout>
</layout>

瀑布流中具体的明细布局-rv_item.xml

        在明细布局里,整体瀑布流墙就两个元素,一个是照片的url另一个是文本框,实现很简单。

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>

        <variable
            name="item"
            type="org.mk.android.demo.demo.staggerdrecyclerview.RVBean" />
    </data>

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <androidx.cardview.widget.CardView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_margin="8dp"
            app:cardCornerRadius="8dp"
            app:cardElevation="4dp">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical">

                <ImageView
                    android:id="@+id/rvImageView"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:adjustViewBounds="true"
                    android:scaleType="fitXY"
                    app:url="@{item.url}" />

                <TextView
                    android:id="@+id/rvTextView"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:textAlignment="center"
                    android:layout_margin="4dp"
                    android:text="@{item.text}" />
            </LinearLayout>
        </androidx.cardview.widget.CardView>
    </LinearLayout>
</layout>

 现在就来看我们的代码。

后端代码

RVBean.java

package org.mk.android.demo.demo.staggerdrecyclerview;

import android.util.Log;
import android.widget.ImageView;

import androidx.databinding.BindingAdapter;

import com.bumptech.glide.Glide;

import java.util.Objects;

public class RVBean {
    private String url;
    private String text;
    private final static String TAG = "DemoStaggerdRecyclerView";

    @BindingAdapter("url")
    public static void loadImg(ImageView imageView, String url) {
        Glide.with(imageView).load(url).into(imageView);
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getText() {
        return text;
    }

    public void setText(String text) {
        this.text = text;
    }

    public RVBean(String url, String text) {
        this.url = url;
        this.text = text;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            //Log.i(TAG, ">>>>>>this==o return true");
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            //Log.i(TAG, ">>>>>>o==null||getClass()!=o.getClass() is false");
            return false;
        }
        RVBean rvBean = (RVBean) o;
        if (rvBean.url.length() != url.length() || rvBean.text.length() != text.length()) {
            //Log.i(TAG, ">>>>>>target length()!=existed url length");
            return false;
        }
        if(url.equals(rvBean.url)&&text.equals(rvBean.text)){
            //Log.i(TAG,">>>>>>url euqlas && text equals");
            return true;
        }else{
            //Log.i(TAG,">>>>>>not url euqlas && text equals");
            return false;
        }
    }

    @Override
    public int hashCode() {
        int hashCode = Objects.hash(url, text);
        //Log.i(TAG, ">>>>>>hashCode->" + hashCode);
        return hashCode;
    }
}

        代码中它定义了两个元素,一个为文本框,一个为用于加载网络图片的url,网络图片我用的是我另一台VM上的Nginx做的静态图片资源服务。

RVAdapter.java

package org.mk.android.demo.demo.staggerdrecyclerview;

import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.RecyclerView;

import com.bumptech.glide.Glide;

import org.mk.android.demo.demo.staggerdrecyclerview.databinding.RvItemBinding;

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;

public class RVAdapter extends RecyclerView.Adapter<RVAdapter.VH> {
    private Context context;
    private List<RVBean> rvBeans;
    private final static String TAG = "DemoStaggerdRecyclerView";

    public RVAdapter(Context context, List<RVBean> rvBeans) {
        this.context = context;
        this.rvBeans = rvBeans;
    }
    @Override
    public int getItemViewType(int position) {
        return position;
    }
    @NonNull
    @Override
    public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return new VH(DataBindingUtil.inflate(
                LayoutInflater.from(context), R.layout.rv_item, parent, false).getRoot());
    }

    @Override
    public void onBindViewHolder(@NonNull VH holder, int position) {
        //try {
            RvItemBinding binding = DataBindingUtil.bind(holder.itemView);
            //binding.rvTextView.setText(rvBeans.get(position).getText());
            binding.setItem(rvBeans.get(position));
            /*
            //Set size
            BitmapFactory.Options options = new BitmapFactory.Options();
            options.inJustDecodeBounds = true;//这个参数设置为true才有效,
            Bitmap bmp = BitmapFactory.decodeFile(rvBeans.get(position).getUrl(), options);
            //这里的bitmap是个空
            int outHeight = options.outHeight;
            int outWidth = options.outWidth;
            Glide.with(context).load(rvBeans.get(position).getUrl()).override(outWidth,
                    outHeight).into(binding.rvImageView);
        } catch (Exception e) {
            Log.e(TAG, ">>>>>>onbindViewHolder error: " + e.getMessage(), e);
        }
             */
    }

    @Override
    public int getItemCount() {
        return rvBeans.size();
    }

    public class VH extends RecyclerView.ViewHolder {
        public VH(@NonNull View itemView) {
            super(itemView);
        }
    }

    //增加外部调用增加一条记录
    public void refreshDatas(List<RVBean> datas) {
        int pc=0;
        if (datas != null && datas.size() > 0) {
            int oldSize = rvBeans.size();
            //List<RVBean> refreshedData = new ArrayList<RVBean>();
            boolean isItemExisted = false;
            for (Iterator<RVBean> newData = datas.iterator(); newData.hasNext(); ) {
                RVBean a = newData.next();
                for (Iterator<RVBean> existedData = rvBeans.iterator(); existedData.hasNext(); ) {
                    RVBean b = existedData.next();
                    if (b.equals(a)) {
                        {
                            isItemExisted = true;
                            //Log.i(TAG, b.getText() + " -> " + b.getUrl() + " is existed");
                            break;
                        }
                    }
                }
                if (!isItemExisted) {
                    pc+=1;
                    rvBeans.add(a);
                }
            }
            Log.i(TAG,">>>>>>pc->"+pc);
            if(pc>0){
                notifyItemRangeChanged(oldSize,rvBeans.size());
            }
        }
    }
}

 核心代码导读

  1. 这个adapter用的正是mvvm设计模式做的adapter;
  2. 这个adapter和网上那些错误、有坑的例子最大的不同在于getItemViewType方法内必须返回position,否则你的瀑布流在上划加载新数据时会产生界面内对照片重新进行左右切换、重排、或者把照片底部留出很大一块空白如:左边垂直排3张,右边一大边空白或者反之亦然的情况;
  3. 必须使用notifyItemRangeChanged来通知刷新新数据,网上很多例子用的是notifyDataSetChange或者是其它相关的notify,它们都是错的,这是因为RecyclerViewer在上划下划时会导致整个瀑布流重新布局、而RecyclerView里用的是Glide异步加载网络图片的,这会导致组件看到有一个组片就去开始计算它的高度而实际这个照片还未加载好因此才会导致RecyclerView在上划下划时整体布局重新刷新和重布局。一定记得这个notifyItemRangeChanged,同时这个方法在使用前加载所有的图片(list数据),传参有两个参数,参数1:加载新数据前原数据行.size(),参数2:新加载数据.size();

        有了adapter我们来看我们的应用了。

        应用前先别急,我们自定义了一个StaggeredGridLayoutManager。

自定义FullyStaggeredGridLayoutManager

        这边先说一下,为什么要自定义这个StaggeredGridLayoutManager?

public class FullyStaggeredGridLayoutManager extends StaggeredGridLayoutManager {

        大家可以认为,这个类是一个策略。这也是网上绝大部分教程根本不说的,这个策略就是从根本上避免RecyclerViewer在上划下划时不要进行左右切换、重新布局、图片闪烁以及“解决Scrollview中嵌套RecyclerView实现瀑布流时无法显示的问题,同时修复了子View显示时底部多出空白区域的问题”用的,我在代码中也做了注释。

        此处要敲一下黑板了。因此整个RecyclerView要做到类淘宝、抖音的这种用户体验必须是adapter里的代码和这个自定义StaggeredGridLayoutManager结合起来才能做到。

        因此我们下面就来看在MainActivity里如何把adapter结合着这个自定义的StaggeredGridLayoutManager的应用吧。

        先上我们自定义的这个StaggeredGridLayoutManager-我们在此把它命名叫做:FullyStaggeredGridLayoutManager的全代码。

FullyStaggeredGridLayoutManager.java代码

package org.mk.android.demo.demo.staggerdrecyclerview;

import android.content.Context;
import android.graphics.Rect;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;


import java.lang.reflect.Field;

/**
 * @descride 解决Scrollview中嵌套RecyclerView实现瀑布流时无法显示的问题,同时修复了子View显示时底部多出空白区域的问题
 */
public class FullyStaggeredGridLayoutManager extends StaggeredGridLayoutManager {
    private static boolean canMakeInsetsDirty = true;
    private static Field insetsDirtyField = null;

    private static final int CHILD_WIDTH = 0;
    private static final int CHILD_HEIGHT = 1;
    private static final int DEFAULT_CHILD_SIZE = 100;
    private int spanCount = 0;

    private final int[] childDimensions = new int[2];
    private int[] childColumnDimensions;

    private int childSize = DEFAULT_CHILD_SIZE;
    private boolean hasChildSize;
    private final Rect tmpRect = new Rect();

    public FullyStaggeredGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr,
                                           int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }

    public FullyStaggeredGridLayoutManager(int spanCount, int orientation) {
        super(spanCount, orientation);
        this.spanCount = spanCount;
    }

    public static int makeUnspecifiedSpec() {
        return View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
    }

    @Override
    public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthSpec,
                          int heightSpec) {
        final int widthMode = View.MeasureSpec.getMode(widthSpec);
        final int heightMode = View.MeasureSpec.getMode(heightSpec);

        final int widthSize = View.MeasureSpec.getSize(widthSpec);
        final int heightSize = View.MeasureSpec.getSize(heightSpec);

        final boolean hasWidthSize = widthMode != View.MeasureSpec.UNSPECIFIED;
        final boolean hasHeightSize = heightMode != View.MeasureSpec.UNSPECIFIED;

        final boolean exactWidth = widthMode == View.MeasureSpec.EXACTLY;
        final boolean exactHeight = heightMode == View.MeasureSpec.EXACTLY;

        final int unspecified = makeUnspecifiedSpec();

        if (exactWidth && exactHeight) {
            // in case of exact calculations for both dimensions let's use default "onMeasure" implementation
            super.onMeasure(recycler, state, widthSpec, heightSpec);
            return;
        }

        final boolean vertical = getOrientation() == VERTICAL;

        initChildDimensions(widthSize, heightSize, vertical);

        int width = 0;
        int height = 0;

        // it's possible to get scrap views in recycler which are bound to old (invalid) adapter entities. This
        // happens because their invalidation happens after "onMeasure" method. As a workaround let's clear the
        // recycler now (it should not cause any performance issues while scrolling as "onMeasure" is never
        // called whiles scrolling)
        recycler.clear();

        final int stateItemCount = state.getItemCount();
        final int adapterItemCount = getItemCount();

        childColumnDimensions = new int[adapterItemCount];
        // adapter always contains actual data while state might contain old data (f.e. data before the animation is
        // done). As we want to measure the view with actual data we must use data from the adapter and not from  the
        // state
        for (int i = 0; i < adapterItemCount; i++) {
            if (vertical) {
                if (!hasChildSize) {
                    if (i < stateItemCount) {
                        // we should not exceed state count, otherwise we'll get IndexOutOfBoundsException. For such items
                        // we will use previously calculated dimensions
                        measureChild(recycler, i, widthSize, unspecified, childDimensions);
                    } else {
                        logMeasureWarning(i);
                    }
                }
                childColumnDimensions[i] = childDimensions[CHILD_HEIGHT];
                //height += childDimensions[CHILD_HEIGHT];
                if (i == 0) {
                    width = childDimensions[CHILD_WIDTH];
                }
                if (hasHeightSize && height >= heightSize) {
                    break;
                }
            } else {
                if (!hasChildSize) {
                    if (i < stateItemCount) {
                        // we should not exceed state count, otherwise we'll get IndexOutOfBoundsException. For such items
                        // we will use previously calculated dimensions
                        measureChild(recycler, i, unspecified, heightSize, childDimensions);
                    } else {
                        logMeasureWarning(i);
                    }
                }
                width += childDimensions[CHILD_WIDTH];
                if (i == 0) {
                    height = childDimensions[CHILD_HEIGHT];
                }
                if (hasWidthSize && width >= widthSize) {
                    break;
                }
            }
        }

        int[] maxHeight = new int[spanCount];
        for (int i = 0; i < adapterItemCount; i++) {
            int position = i % spanCount;
            if (i < spanCount) {
                maxHeight[position] += childColumnDimensions[i];
            } else if (position < spanCount) {
                int mixHeight = maxHeight[0];
                int mixPosition = 0;
                for (int j = 0; j < spanCount; j++) {
                    if (mixHeight > maxHeight[j]) {
                        mixHeight = maxHeight[j];
                        mixPosition = j;
                    }
                }
                maxHeight[mixPosition] += childColumnDimensions[i];
            }
        }

        for (int i = 0; i < spanCount; i++) {
            for (int j = 0; j < spanCount - i - 1; j++) {
                if (maxHeight[j] < maxHeight[j + 1]) {
                    int temp = maxHeight[j];
                    maxHeight[j] = maxHeight[j + 1];
                    maxHeight[j + 1] = temp;
                }
            }
        }
        height = maxHeight[0];//this is max height

        if (exactWidth) {
            width = widthSize;
        } else {
            width += getPaddingLeft() + getPaddingRight();
            if (hasWidthSize) {
                width = Math.min(width, widthSize);
            }
        }

        if (exactHeight) {
            height = heightSize;
        } else {
            height += getPaddingTop() + getPaddingBottom();
            if (hasHeightSize) {
                height = Math.min(height, heightSize);
            }
        }

        setMeasuredDimension(width, height);
    }

    private void logMeasureWarning(int child) {
        if (BuildConfig.DEBUG) {
            Log.w("LinearLayoutManager", "Can't measure child #"
                    + child
                    + ", previously used dimensions will be reused."
                    + "To remove this message either use #setChildSize() method or don't run RecyclerView animations");
        }
    }

    private void initChildDimensions(int width, int height, boolean vertical) {
        if (childDimensions[CHILD_WIDTH] != 0 || childDimensions[CHILD_HEIGHT] != 0) {
            // already initialized, skipping
            return;
        }
        if (vertical) {
            childDimensions[CHILD_WIDTH] = width;
            childDimensions[CHILD_HEIGHT] = childSize;
        } else {
            childDimensions[CHILD_WIDTH] = childSize;
            childDimensions[CHILD_HEIGHT] = height;
        }
    }

    @Override public void setOrientation(int orientation) {
        // might be called before the constructor of this class is called
        //noinspection ConstantConditions
        if (childDimensions != null) {
            if (getOrientation() != orientation) {
                childDimensions[CHILD_WIDTH] = 0;
                childDimensions[CHILD_HEIGHT] = 0;
            }
        }
        super.setOrientation(orientation);
    }

    public void clearChildSize() {
        hasChildSize = false;
        setChildSize(DEFAULT_CHILD_SIZE);
    }

    public void setChildSize(int childSize) {
        hasChildSize = true;
        if (this.childSize != childSize) {
            this.childSize = childSize;
            requestLayout();
        }
    }

    private void measureChild(RecyclerView.Recycler recycler, int position, int widthSize,
                              int heightSize, int[] dimensions) {
        final View child;
        try {
            child = recycler.getViewForPosition(position);
        } catch (IndexOutOfBoundsException e) {
            if (BuildConfig.DEBUG) {
                Log.w("LinearLayoutManager",
                        "LinearLayoutManager doesn't work well with animations. Consider switching them off",
                        e);
            }
            return;
        }

        final RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) child.getLayoutParams();

        final int hPadding = getPaddingLeft() + getPaddingRight();
        final int vPadding = getPaddingTop() + getPaddingBottom();

        final int hMargin = p.leftMargin + p.rightMargin;
        final int vMargin = p.topMargin + p.bottomMargin;

        // we must make insets dirty in order calculateItemDecorationsForChild to work
        makeInsetsDirty(p);
        // this method should be called before any getXxxDecorationXxx() methods
        calculateItemDecorationsForChild(child, tmpRect);

        final int hDecoration = getRightDecorationWidth(child) + getLeftDecorationWidth(child);
        final int vDecoration = getTopDecorationHeight(child) + getBottomDecorationHeight(child);

        final int childWidthSpec =
                getChildMeasureSpec(widthSize, hPadding + hMargin + hDecoration, p.width,
                        canScrollHorizontally());
        final int childHeightSpec =
                getChildMeasureSpec(heightSize, vPadding + vMargin + vDecoration, p.height,
                        canScrollVertically());

        child.measure(childWidthSpec, childHeightSpec);

        dimensions[CHILD_WIDTH] = getDecoratedMeasuredWidth(child) + p.leftMargin + p.rightMargin;
        dimensions[CHILD_HEIGHT] = getDecoratedMeasuredHeight(child) + p.bottomMargin + p.topMargin;

        // as view is recycled let's not keep old measured values
        makeInsetsDirty(p);
        recycler.recycleView(child);
    }

    private static void makeInsetsDirty(RecyclerView.LayoutParams p) {
        if (!canMakeInsetsDirty) {
            return;
        }
        try {
            if (insetsDirtyField == null) {
                insetsDirtyField = RecyclerView.LayoutParams.class.getDeclaredField("mInsetsDirty");
                insetsDirtyField.setAccessible(true);
            }
            insetsDirtyField.set(p, true);
        } catch (NoSuchFieldException e) {
            onMakeInsertDirtyFailed();
        } catch (IllegalAccessException e) {
            onMakeInsertDirtyFailed();
        }
    }

    private static void onMakeInsertDirtyFailed() {
        canMakeInsetsDirty = false;
        if (BuildConfig.DEBUG) {
            Log.w("LinearLayoutManager",
                    "Can't make LayoutParams insets dirty, decorations measurements might be incorrect");
        }
    }
}

 MainActivity.java

        从这儿开始我们要进入正题了,这边要说真正的RecyclerView的应用了。

        在此演示代码块里为了同时便于初学者和正在寻找RecyclerView上划下划时错位过大、重新布局影响体验的老手同时阅读和查询问题时方便,我要说一下整个Demo代码运行的设计思路如下:

  1. 上手先加载12条数据;
  2. 对上划(手指按住屏幕向上拉),一直拉、拉、拉,拉到第12条,触发了RecyclerView的onScrollStateChanged中的“往上拉拉不动”事件后开始再加载6条数据,以模拟实际项目中的“翻页时走一下后台API取新数据”的过程;
package org.mk.android.demo.demo.staggerdrecyclerview;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.databinding.DataBindingUtil;
import androidx.recyclerview.widget.DefaultItemAnimator;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import androidx.recyclerview.widget.StaggeredGridLayoutManager;

import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;

import org.mk.android.demo.demo.staggerdrecyclerview.databinding.ActivityMainBinding;

import java.util.ArrayList;
import java.util.List;

public class MainActivity extends AppCompatActivity {
    private ActivityMainBinding binding;
    private List<RVBean> rvBeanList = new ArrayList<>();
    private RVAdapter adapter;
    private final static String TAG = "DemoStaggerdRecyclerView";
    private final static String CDN_URL="http://172.16.4.249/mkcdn";
    private FullyStaggeredGridLayoutManager slm=null;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        binding = DataBindingUtil.inflate(LayoutInflater.from(this), R.layout.activity_main, null, false);
        setContentView(binding.getRoot());
        slm=new FullyStaggeredGridLayoutManager(2,
                FullyStaggeredGridLayoutManager.VERTICAL);

        binding.rv.setLayoutManager(slm);

        ((SimpleItemAnimator)binding.rv.getItemAnimator()).setSupportsChangeAnimations(false);
        ((DefaultItemAnimator) binding.rv.getItemAnimator()).setSupportsChangeAnimations(false);

        binding.rv.getItemAnimator().setChangeDuration(0);
        binding.rv.setHasFixedSize(true);
        initData();

    }

    private void initData() {

        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_13.jpeg", "1"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_14.jpeg", "2"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_15.jpeg", "3"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_16.jpeg", "4"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_17.jpeg", "5"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_18.jpeg", "6"));
        rvBeanList.add(new RVBean(CDN_URL+"img/recommend/recom_19.jpeg", "7"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_20.jpeg", "8"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_21.jpeg", "9"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_22.jpeg", "10"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_23.jpeg", "11"));
        rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_24.jpeg", "12"));
        adapter = new RVAdapter(this, rvBeanList);
        //主要就是这个LayoutManager,就是用这个来实现瀑布流的,2表示有2列(垂直)或3行(水平),我们这里用的垂直VERTICAL

        //binding.rv.addItemDecoration(new SpaceItemDecoration(2, 20));


        binding.rv.addOnScrollListener(new RecyclerView.OnScrollListener() {
            @Override
            public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
                super.onScrollStateChanged(recyclerView, newState);
                if (!recyclerView.canScrollVertically(1) && newState == RecyclerView.SCROLL_STATE_IDLE) {
                    Log.i(TAG, "上拉拉不动时触发加载新数据");
                    rvBeanList = new ArrayList<>();
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_25.jpeg", "13"));
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_26.jpeg", "14"));
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_27.jpeg", "15"));
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_28.jpeg", "16"));
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_29.jpeg", "17"));
                    rvBeanList.add(new RVBean(CDN_URL+"/img/recommend/recom_30.jpeg", "18"));
                    adapter.refreshDatas(rvBeanList);
                }
                if (!recyclerView.canScrollVertically(-1) && newState == RecyclerView.SCROLL_STATE_IDLE) {
                    Log.i(TAG, "下拉拉不动时触发加载新数据");
                }
            }
            @Override
            public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
                super.onScrolled(recyclerView, dx, dy);
                slm.invalidateSpanAssignments();//防止第一行到顶部有空白
            }
        });
        //((SimpleItemAnimator)RecyclerView.getItemAnimator()).setSupportsChangeAnimations(false);
        binding.rv.setAdapter(adapter);
    }
}

核心代码导读:

        以下这一陀就是我说的使用自定义的StaggeredGridLayoutManager+adapter中的事件覆盖一起实现了著名的RecyclerView上划下划时重新布局、翻页的梗。

         同时,一定要记得覆盖RecyclerView的onScrolled事件,在事件中加入这样的代码

@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
     super.onScrolled(recyclerView, dx, dy);
     slm.invalidateSpanAssignments();//防止第一行到顶部有空白
}

总结

正确的做法

        因此这边总结一下共有5个点,做到这5个点才能真正避免网上一堆的关于RecyclerView快速上划下划时整个界面产生了春左右列切换、重新布局、布局不合理如:左边垂直排了3-4个图片而把右边留出一大块空白的梗的综合手段:

  1. 必须使用一个自定义的StaggeredGridLayoutManager,你可以直接使用我博客中的代码,它来自于我正在制作的上生产的mao-sir.com的app中的代码,这是我的个人的一个开源中台产品;
  2. 必须要设置上面代码截图中的4个属性即:

    SimpleItemAnimator里的setSupportsChangeAnimations(false);
    DefaultItemAnimator里的setSupportsChangeAnimations(false);
    setChangeDuration(0);
    setHasFixedSize(true);

  3. 必须要覆盖RecyclerView的onScrolled方法,在方法里设置防止第一行到顶部有空白的操作;

  4. 必须要在adapter里覆盖getItemViewType,在方法内返回position;

  5. 必须要在adapter里刷新数据时使用:notifyItemRangeChanged;

错误的做法

        这边我说网上绝大部分示例是错的可能是客气了点,因该说可以搜到的全是错的。我们也来总结一下,希望各位不要再去踩这种坑了。

  1. 把图片的尺寸预先存在后台、每次接口取图片时后台把这个尺寸返回给到RecyclerView的adapter。。。这是得有多。。。无聊的做法,覆盖一个getItemViewType不就同样实现了这样的手法?
  2. setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_NONE)的做法是错的,设完后图片要么在一开始进入界面时显示不出要么显示不全,我不知道这个问题最早是谁想出的解决办法?怎么自己也不去验证一下对不对?
  3. 网上还有一种手法也是错的离谱,就是在onBindViewHolder方法里通过BitMap+GLIDE重设图片的尺寸,这种方法根本无效;

        下面我们来感受一下使用了网上错误的手法后,这个瀑布流会变成什么样,大家就能感受到我说的这种:体验问题了。这种体验问题在APP上如果没有做好会直接要了你这家企业的“口碑的命”。

来看错误手法导致的差劲的瀑布流体验

        请看下面的“反例”的演示,注意到了上划或者下划时左右列切换、重排的问题了没?给大家感受一下。

后记

        此处我们需要写一下RecyclerView在生产上真正使用时在设计上的一个补充点。

        根据上面的代码我们可以看到我用了上划触发上划到一页底部并实现翻页的无缝衔接的体验。

        但实际在生产上我们会遇到上千~近万张图片的前后台传输。如果图片一多、翻页一多,因为这个翻而是介于前后台交互的一个动作,因此有时在遇有弱网或者是网络抖动时卡死Android主进程并导致App闪退、卡屏、白屏等现象。

        所以,我这边给出万级QPS并发下的瀑布流设计上的两个点:

  1. APP的首页、分类、搜索的list、瀑布流、三行三列这种界面的数据必须来自后台的es,而不是去什么后台查数据库这么来一下。在中/后台,我们通过企业内部的运营管理平台对于内容、商品进行“上下架”后会触发一个ES或者是RediSearch(我的开源产品里已经彻底不用ES了)的全量或者是增量索引;
  2. 在APP端,不能翻一页就去后台取一下!而是APP端也要做一层“缓存”,可以把已经获取到的数据存在本地的内嵌存储,本地取不到再去取后/中台的API接口获取数据;

        以此充分极大化加快我们APP端的反映速度同时取得最优的用户体验。

好,结束今天的教程。大家不妨自己动一下手试试看效果去吧。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/358433.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

32岁,薪水被应届生倒挂,裸辞了

今年 32 岁&#xff0c;我从公司离职了&#xff0c;是裸辞。 前段时间&#xff0c;我有一件事情一直憋在心里很难受&#xff0c;想了很久也没找到合适的人倾诉&#xff0c;就借着今天写出来。 我一个十几年开发经验&#xff0c;八年 软件测试 经验的职场老人&#xff0c;我慢…

markdown常用语法--花括号(超详细)

&#x1f48c; 所属专栏&#xff1a;【Markdown常用语法】 &#x1f600; 作  者&#xff1a;我是夜阑的狗&#x1f436; &#x1f680; 个人简介&#xff1a;一个正在努力学技术的CV工程师&#xff0c;专注基础和实战分享 &#xff0c;欢迎咨询&#xff01; &#x1…

2023年,自动化测试岗位有这么吃香吗

测试人员需要具备自动化测试或者测试开发能力&#xff0c;已经成为测试行业内容的一种显在趋势&#xff0c;而且这种趋势呈放大态势&#xff0c;其发展前景是十分明朗的。 2022疫情期间&#xff0c;就业大环境不是很理想&#xff0c;目前呢&#xff0c;企业倾向于招自动化测试…

易基因项目文章| 组蛋白ChIP-seq揭示烟粉虱含菌细胞共生菌调控宿主生殖的新机制

喜报&#xff1a;易基因组蛋白ChIP-seq研究成果见刊《Cell Reports》2023年02月10日&#xff0c;沈阳农业大学植物保护学院博士研究生姚亚林为第一作者、栾军波教授为论文通讯作者在《Cell Reports》杂志以“A bacteriocyte symbiont determines whitefly sex ratio by regulat…

④ 链表

24. 两两交换链表中的节点 题目链接&#xff1a;https://leetcode.cn/problems/swap-nodes-in-pairs/ 注意点&#xff1a; 遍历链表的时候什么时候截止&#xff08;避免空指针异常或无限死循环的问题&#xff09;&#xff1f; 节点数量为偶数或链表为空时&#xff0c;cur.ne…

关于欧拉角你需要知道几个点

基础理解&#xff0c;参照&#xff1a;https://www.cnblogs.com/Estranged-Tech/p/16903025.html 欧拉角、万向节死锁&#xff08;锁死&#xff09;理解 一、欧拉角理解 举例讲解 欧拉角用三次独立的绕确定的轴旋转角度来表示姿态。如下图所示 经过三次旋转&#xff0c;旋…

原创不易,坚持更难

早上CSDN发消息&#xff0c;今天是创作满三年的纪念日&#xff0c;邀请写一篇博文&#xff0c;谈谈感受 开博原因 2020年是一个特殊的年份&#xff0c;疫情刚爆发第一年&#xff0c;也是第一次居家办公&#xff0c;从过完年就一直居家办公&#xff0c;一直居家了38天。2020年…

宗教生活污水处理项目的设计原则

我国的历史源远流长&#xff0c;许多传统文化都延续了上千年以上。同时&#xff0c;我国也是宗教信仰自由的国家&#xff0c;儒释道文化相互影响&#xff0c;交相辉映。游人穿过山间小道&#xff0c;走到寺院庄严礼拜&#xff0c;游客和信众的增多&#xff0c;使教堂、寺庙等宗…

机器学习笔记之谱聚类(一)k-Means聚类算法介绍

机器学习笔记之谱聚类——K-Means聚类算法介绍引言回顾&#xff1a;高斯混合模型聚类任务基本介绍距离计算k-Means\text{k-Means}k-Means算法介绍k-Means\text{k-Means}k-Means算法示例k-Means\text{k-Means}k-Means算法与高斯混合模型的关系k-Means\text{k-Means}k-Means算法的…

CTFer成长之路之CTF中的SQL注入

CTF中的SQL注入CTF SQL注入 SQL注入-1 题目描述: 暂无 docker-compose.yml version: 3.2services:web:image: registry.cn-hangzhou.aliyuncs.com/n1book/web-sql-1:latestports:- 80:80启动方式 docker-compose up -d 题目Flag n1book{union_select_is_so_cool} Wri…

PyQt5数据库开发1 4.3 QSqlTableModel 之 相关槽函数的实现(多图长文详解)

目录 一、打开数据库表 1. 写打开数据库的槽函数 2. 运行后发现数据库可以打开了 3. ODBC配通了&#xff0c;数据库还是打不开 4. 写在tableView上显示数据库表的函数 5. 运行后发现表可以显示了 6. 代码分析 7. 添加列名称 8. 根据内容调整列宽 9. 备注&#xff1a;…

Kaggle系列之CIFAR-10图像识别分类(残差网络模型ResNet-18)

CIFAR-10数据集在计算机视觉领域是一个很重要的数据集&#xff0c;很有必要去熟悉它&#xff0c;我们来到Kaggle站点&#xff0c;进入到比赛页面&#xff1a;https://www.kaggle.com/competitions/cifar-10CIFAR-10是8000万小图像数据集的一个子集&#xff0c;由60000张32x32彩…

spring cloud gateway集成sentinel并扩展支持restful api进行url粒度的流量治理

sentinel集成网关支持restful接口进行url粒度的流量治理前言使用网关进行总体流量治理&#xff08;sentinel版本&#xff1a;1.8.6&#xff09;1、cloud gateway添加依赖:2、sentinel配置3、网关类型项目配置4、通过zk事件监听刷新上报api分组信息1、非网关项目上报api分组信息…

I.MX6ULL_Linux_系统篇(16) uboot分析-启动流程

原文链接&#xff1a;I.MX6ULL_系统篇(16) uboot分析-启动流程 – WSY Personal Blog (cpolar.cn) 前面我们详细的分析了 uboot 的顶层 Makefile&#xff0c;了解了 uboot 的编译流程。本章我们来详细的分析一下 uboot 的启动流程&#xff0c;理清 uboot 是如何启动的。通过对 …

虹科资讯| 虹科AR荣获汽车后市场“20佳”维修工具评委会提名奖!

2022 虹科荣获20佳维修工具 评委会提名奖 特大喜讯&#xff0c;在2月16日《汽车维修与保养》杂志主办的第十八届汽车后市场“20佳”评选活动中&#xff0c;虹科的产品“M400智能AR眼镜”凭借在AR领域的专业实力&#xff0c;通过层层筛选&#xff0c;在102款入围产品中脱颖而出…

GIT:【基础三】Git工作核心原理

目录 一、Git本地四个工作区域 二、Git提交文件流程 一、Git本地四个工作区域 工作目录(Working Directory)&#xff1a;电脑上存放开发代码的地方。暂存区(Stage/Index)&#xff1a;用于l临时存放改动的文件&#xff0c;本质上只是一个文件&#xff0c;保存即将提交到文件列…

[ 对比学习篇 ] 经典网络模型 —— Contrastive Learning

&#x1f935; Author &#xff1a;Horizon Max ✨ 编程技巧篇&#xff1a;各种操作小结 &#x1f3c6; 神经网络篇&#xff1a;经典网络模型 &#x1f4bb; 算法篇&#xff1a;再忙也别忘了 LeetCode [ 对比学习篇 ] 经典网络模型 —— Contrastive Learning&#x1f680; …

MongoDB介绍及使用教程

文章目录一、MongoDB介绍1. 什么是MongoDB2. 为什么要用MongoDB3. MongoDB的应用场景4. MongoDB基本概念二、MongoDB使用教程1.下载安装&#xff08;Windows&#xff09;2.MongoDB Conpass简单使用&#xff08;选学&#xff09;3.使用navicat连接MongoDB4.JAVA项目中使用MongoD…

JVM11 垃圾回收

1.1GC分类与性能指标 垃圾收集器没有在规范中进行过多的规定&#xff0c;可以由不同的厂商、不同版本的JVM来实现。 从不同角度分析垃圾收集器&#xff0c;可以将GC分为不同的类型。 Java不同版本新特性 语法层面&#xff1a;Lambda表达式、switch、自动拆箱装箱、enumAPI层面…

AI稳定生成图工业链路打造

前沿这篇文章会以比较轻松的方式&#xff0c;跟大家交流下如何控制文本生成图片的质量。要知道如何控制文本生成质量&#xff0c;那么我们首先需要知道我们有哪些可以控制的参数和模块。要知道我们有哪些控制的参数和模块&#xff0c;我们就得知道我们文本生成图片的这架机器或…