这个“StickyScrollView”如何实现将视图固定在ScrollView顶部?

8
通常情况下,粘性标题的工作方式是将可滚动数据划分为各自具有标题的部分,并随着向下滚动,后续部分的标题取代了ScrollView顶部的标题。
我需要在各自的部分中有额外的粘性标题。例如,如果header1固定在顶部,则它的第一个部分的标题——header1a——会固定在其下方,但是当我们到达第1b节时,1b的标题将替换1a的标题,但保留header1固定在原地;最后,当我们到达第2节时,header2将替换前一节的当前固定标题——header1header1b
这里是实现一维粘性标题的ScrollView实现:
https://github.com/emilsjolander/StickyScrollViewItems
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AlphaAnimation;

import java.util.ArrayList;

/**
 *
 * @author Emil Sj�lander - sjolander.emil@gmail.com
 *
 */
public class StickyScrollView extends ScrollView {

    /**
     * Tag for views that should stick and have constant drawing. e.g. TextViews, ImageViews etc
     */
    public static final String STICKY_TAG = "sticky";

    /**
     * Flag for views that should stick and have non-constant drawing. e.g. Buttons, ProgressBars etc
     */
    public static final String FLAG_NONCONSTANT = "-nonconstant";

    /**
     * Flag for views that have aren't fully opaque
     */
    public static final String FLAG_HASTRANSPARANCY = "-hastransparancy";

    /**
     * Default height of the shadow peeking out below the stuck view.
     */
    private static final int DEFAULT_SHADOW_HEIGHT = 10; // dp;

    private ArrayList<View> mStickyViews;
    private View mCurrentlyStickingView;
    private float mStickyViewTopOffset;
    private int mStickyViewLeftOffset;
    private boolean mRedirectTouchesToStickyView;
    private boolean mClippingToPadding;
    private boolean mClipToPaddingHasBeenSet;

    private int mShadowHeight;
    private Drawable mShadowDrawable;

    private final Runnable mInvalidateRunnable = new Runnable() {

        @Override
        public void run() {
            if(mCurrentlyStickingView !=null){
                int l = getLeftForViewRelativeOnlyChild(mCurrentlyStickingView);
                int t  = getBottomForViewRelativeOnlyChild(mCurrentlyStickingView);
                int r = getRightForViewRelativeOnlyChild(mCurrentlyStickingView);
                int b = (int) (getScrollY() + (mCurrentlyStickingView.getHeight() + mStickyViewTopOffset));
                invalidate(l,t,r,b);
            }
            postDelayed(this, 16);
        }
    };

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

    public StickyScrollView(Context context, AttributeSet attrs) {
        this(context, attrs, android.R.attr.scrollViewStyle);
    }

    public StickyScrollView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        setup();



        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.StickyScrollView, defStyle, 0);

        final float density = context.getResources().getDisplayMetrics().density;
        int defaultShadowHeightInPix = (int) (DEFAULT_SHADOW_HEIGHT * density + 0.5f);

        mShadowHeight = a.getDimensionPixelSize(
                R.styleable.StickyScrollView_stuckShadowHeight,
                defaultShadowHeightInPix);

        int shadowDrawableRes = a.getResourceId(
                R.styleable.StickyScrollView_stuckShadowDrawable, -1);

        if (shadowDrawableRes != -1) {
            mShadowDrawable = context.getResources().getDrawable(
                    shadowDrawableRes);
        }

        a.recycle();
    }

    /**
     * Sets the height of the shadow drawable in pixels.
     *
     * @param height
     */
    public void setShadowHeight(int height) {
        mShadowHeight = height;
    }


    public void setup(){
        mStickyViews = new ArrayList<View>();
    }

    private int getLeftForViewRelativeOnlyChild(View v){
        int left = v.getLeft();
        while(v.getParent() != getChildAt(0)){
            v = (View) v.getParent();
            left += v.getLeft();
        }
        return left;
    }

    private int getTopForViewRelativeOnlyChild(View v){
        int top = v.getTop();
        while(v.getParent() != getChildAt(0)){
            v = (View) v.getParent();
            top += v.getTop();
        }
        return top;
    }

    private int getRightForViewRelativeOnlyChild(View v){
        int right = v.getRight();
        while(v.getParent() != getChildAt(0)){
            v = (View) v.getParent();
            right += v.getRight();
        }
        return right;
    }

    private int getBottomForViewRelativeOnlyChild(View v){
        int bottom = v.getBottom();
        while(v.getParent() != getChildAt(0)){
            v = (View) v.getParent();
            bottom += v.getBottom();
        }
        return bottom;
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        if(!mClipToPaddingHasBeenSet){
            mClippingToPadding = true;
        }
        notifyHierarchyChanged();
    }

    @Override
    public void setClipToPadding(boolean clipToPadding) {
        super.setClipToPadding(clipToPadding);
        mClippingToPadding = clipToPadding;
        mClipToPaddingHasBeenSet = true;
    }

    @Override
    public void addView(View child) {
        super.addView(child);
        findStickyViews(child);
    }

    @Override
    public void addView(View child, int index) {
        super.addView(child, index);
        findStickyViews(child);
    }

    @Override
    public void addView(View child, int index, android.view.ViewGroup.LayoutParams params) {
        super.addView(child, index, params);
        findStickyViews(child);
    }

    @Override
    public void addView(View child, int width, int height) {
        super.addView(child, width, height);
        findStickyViews(child);
    }

    @Override
    public void addView(View child, android.view.ViewGroup.LayoutParams params) {
        super.addView(child, params);
        findStickyViews(child);
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        if(mCurrentlyStickingView != null){
            canvas.save();
            canvas.translate(getPaddingLeft() + mStickyViewLeftOffset, getScrollY() + mStickyViewTopOffset + (mClippingToPadding ? getPaddingTop() : 0));

            canvas.clipRect(0, (mClippingToPadding ? -mStickyViewTopOffset : 0), getWidth() - mStickyViewLeftOffset,mCurrentlyStickingView.getHeight() + mShadowHeight + 1);

            if (mShadowDrawable != null) {
                int left = 0;
                int right = mCurrentlyStickingView.getWidth();
                int top = mCurrentlyStickingView.getHeight();
                int bottom = mCurrentlyStickingView.getHeight() + mShadowHeight;
                mShadowDrawable.setBounds(left, top, right, bottom);
                mShadowDrawable.draw(canvas);
            }

            canvas.clipRect(0, (mClippingToPadding ? -mStickyViewTopOffset : 0), getWidth(), mCurrentlyStickingView.getHeight());
            if(getStringTagForView(mCurrentlyStickingView).contains(FLAG_HASTRANSPARANCY)){
                showView(mCurrentlyStickingView);
                mCurrentlyStickingView.draw(canvas);
                hideView(mCurrentlyStickingView);
            }else{
                mCurrentlyStickingView.draw(canvas);
            }
            canvas.restore();
        }
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        if(ev.getAction()==MotionEvent.ACTION_DOWN){
            mRedirectTouchesToStickyView = true;
        }

        if(mRedirectTouchesToStickyView){
            mRedirectTouchesToStickyView = mCurrentlyStickingView != null;
            if(mRedirectTouchesToStickyView){
                mRedirectTouchesToStickyView =
                        ev.getY()<=(mCurrentlyStickingView.getHeight()+ mStickyViewTopOffset) &&
                                ev.getX() >= getLeftForViewRelativeOnlyChild(mCurrentlyStickingView) &&
                                ev.getX() <= getRightForViewRelativeOnlyChild(mCurrentlyStickingView);
            }
        }else if(mCurrentlyStickingView == null){
            mRedirectTouchesToStickyView = false;
        }
        if(mRedirectTouchesToStickyView){
            ev.offsetLocation(0, -1*((getScrollY() + mStickyViewTopOffset) - getTopForViewRelativeOnlyChild(mCurrentlyStickingView)));
        }
        return super.dispatchTouchEvent(ev);
    }

    private boolean hasNotDoneActionDown = true;

    @Override
    public boolean onTouchEvent(MotionEvent ev) {
        if(mRedirectTouchesToStickyView){
            ev.offsetLocation(0, ((getScrollY() + mStickyViewTopOffset) - getTopForViewRelativeOnlyChild(mCurrentlyStickingView)));
        }

        if(ev.getAction()==MotionEvent.ACTION_DOWN){
            hasNotDoneActionDown = false;
        }

        if(hasNotDoneActionDown){
            MotionEvent down = MotionEvent.obtain(ev);
            down.setAction(MotionEvent.ACTION_DOWN);
            super.onTouchEvent(down);
            hasNotDoneActionDown = false;
        }

        if(ev.getAction()==MotionEvent.ACTION_UP || ev.getAction()==MotionEvent.ACTION_CANCEL){
            hasNotDoneActionDown = true;
        }

        return super.onTouchEvent(ev);
    }

    @Override
    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
        super.onScrollChanged(l, t, oldl, oldt);
        doTheStickyThing();
    }

    private void doTheStickyThing() {
        View viewThatShouldStick = null;
        View approachingStickyView = null;
        for(View v : mStickyViews){
            int viewTop = getTopForViewRelativeOnlyChild(v) - getScrollY() + (mClippingToPadding ? 0 : getPaddingTop());
            if(viewTop<=0){
                if(viewThatShouldStick==null || viewTop>(getTopForViewRelativeOnlyChild(viewThatShouldStick) - getScrollY() + (mClippingToPadding ? 0 : getPaddingTop()))){
                    viewThatShouldStick = v;
                }
            }else{
                if(approachingStickyView == null || viewTop<(getTopForViewRelativeOnlyChild(approachingStickyView) - getScrollY() + (mClippingToPadding ? 0 : getPaddingTop()))){
                    approachingStickyView = v;
                }
            }
        }
        if(viewThatShouldStick!=null){
            mStickyViewTopOffset = approachingStickyView == null ? 0 : Math.min(0, getTopForViewRelativeOnlyChild(approachingStickyView) - getScrollY()  + (mClippingToPadding ? 0 : getPaddingTop()) - viewThatShouldStick.getHeight());
            if(viewThatShouldStick != mCurrentlyStickingView){
                if(mCurrentlyStickingView !=null){
                    stopStickingCurrentlyStickingView();
                }
                // only compute the left offset when we start sticking.
                mStickyViewLeftOffset = getLeftForViewRelativeOnlyChild(viewThatShouldStick);
                startStickingView(viewThatShouldStick);
            }
        }else if(mCurrentlyStickingView !=null){
            stopStickingCurrentlyStickingView();
        }
    }

    private void startStickingView(View viewThatShouldStick) {
        mCurrentlyStickingView = viewThatShouldStick;
        if(getStringTagForView(mCurrentlyStickingView).contains(FLAG_HASTRANSPARANCY)){
            hideView(mCurrentlyStickingView);
        }
        if(((String) mCurrentlyStickingView.getTag()).contains(FLAG_NONCONSTANT)){
            post(mInvalidateRunnable);
        }
    }

    private void stopStickingCurrentlyStickingView() {
        if(getStringTagForView(mCurrentlyStickingView).contains(FLAG_HASTRANSPARANCY)){
            showView(mCurrentlyStickingView);
        }
        mCurrentlyStickingView = null;
        removeCallbacks(mInvalidateRunnable);
    }

    /**
     * Notify that the sticky attribute has been added or removed from one or more views in the View hierarchy
     */
    public void notifyStickyAttributeChanged(){
        notifyHierarchyChanged();
    }

    private void notifyHierarchyChanged(){
        if(mCurrentlyStickingView !=null){
            stopStickingCurrentlyStickingView();
        }
        mStickyViews.clear();
        findStickyViews(getChildAt(0));
        doTheStickyThing();
        invalidate();
    }

    private void findStickyViews(View v) {
        if(v instanceof ViewGroup){
            ViewGroup vg = (ViewGroup)v;
            for(int i = 0 ; i<vg.getChildCount() ; i++){
                String tag = getStringTagForView(vg.getChildAt(i));
                if(tag!=null && tag.contains(STICKY_TAG)){
                    mStickyViews.add(vg.getChildAt(i));
                }else if(vg.getChildAt(i) instanceof ViewGroup){
                    findStickyViews(vg.getChildAt(i));
                }
            }
        }else{
            String tag = (String) v.getTag();
            if(tag!=null && tag.contains(STICKY_TAG)){
                mStickyViews.add(v);
            }
        }
    }

    private String getStringTagForView(View v){
        Object tagObject = v.getTag();
        return String.valueOf(tagObject);
    }

    private void hideView(View v) {
        if(Build.VERSION.SDK_INT>=11){
            v.setAlpha(0);
        }else{
            AlphaAnimation anim = new AlphaAnimation(1, 0);
            anim.setDuration(0);
            anim.setFillAfter(true);
            v.startAnimation(anim);
        }
    }

    private void showView(View v) {
        if(Build.VERSION.SDK_INT>=11){
            v.setAlpha(1);
        }else{
            AlphaAnimation anim = new AlphaAnimation(0, 1);
            anim.setDuration(0);
            anim.setFillAfter(true);
            v.startAnimation(anim);
        }
    }

}

我想要做的是适应我的需求,但我尝试浏览这个实现方式以了解它如何实现将视图粘贴在ScrollView的顶部,但我无法弄清楚它是如何实现的。有人知道这是如何工作的吗?

编辑:

这是我想要应用此概念的布局:
*请记住,Headers(Header 1和2)是自定义的ViewGroups,包含Sub-Headers(Header 1a,1b,2a),后者也是自定义的ViewGroups,包含自定义视图,即Items

enter image description here


如果您希望在滚动时视图保持在顶部,请在xml布局中添加一个带有android:tag="sticky"的sticky标签。 - PN10
请提供一些您的需求图形。这将有助于更好地理解您的需求。 - Pravin Divraniya
@PN10,这不是将视图固定在顶部的方式。这是识别要固定的内容的方式。我想知道使视图看起来像被固定在顶部的代码是如何工作的。 - shoe
3个回答

6
您正在使用的StickyScrollView只是保存了一个标记,用于判断是否应该粘贴以及如果不是,则其头部是滚动视图的哪个子项,并根据此将其维护为第一个子视图。 如果您想仅使用此StickyScrollView,则必须修改它并维护一个额外的标记作为子标题。 我建议您使用此列表视图而不是使用此ScrollView。 它非常易于实现,并且工作得很好。


4
您可以使用 header-decor 来实现您的需求。它内部使用了 RecyclerView,因此建议使用它。请查看下面动图中的双重标题部分。

enter image description here

希望这能对您有所帮助。

这几乎是我想要的。唯一的问题是我正在使用 ScrollView 而不是 RecyclerView。我编辑了问题并添加了我正在尝试应用此概念的布局。我很乐意将其适应于 ScrollView 并分享它。我只需要理解问题中发布的实现方式如何做到这一点。 - shoe

4

这不是什么高深的理论。理解这个问题有两个关键要素。

首先是在方法doTheStickyThing中。它会计算应该将哪些内容固定在页面上,而哪些内容应该跟随滚动而移动。

最初的步骤是确定应该固定哪一个标题。一旦页面被滚动,你会看到部分内容在可视区域上方和下方。你需要固定最靠近顶部且仍然在可视区域上方的最下面的标题。因此你会看到很多类似如下的表达式:

         getTopForViewRelativeOnlyChild(viewThatShouldStick) - getScrollY() + (clippingToPadding ? 0 : getPaddingTop()))

那个结果值只是视图顶部相对于滚动视图顶部的偏移量。如果标题在滚动视图顶部之上,则该值为负数。因此,您需要具有最大偏移值且仍小于或等于零的标题。获胜的视图被分配给viewThatShouldStick
现在,您需要知道哪个后续标题可能在滚动时开始将其推开。这将分配给approachingView 如果接近视图正在将标题挤出路,您必须抵消标题的顶部。该值分配给stickyViewTopOffset 第二个关键部分是绘制标题。这是在dispatchDraw中完成的。
使视图看起来“卡住”的诀窍是:基于其当前边界,正常渲染逻辑希望将该标头放置在某个位置。我们可以将画布(translate)移动到该标头下方,以便它在滚动视图顶部绘制,而不是通常绘制的任何地方。然后我们告诉视图绘制自己。这发生在所有列表项视图已经被绘制之后,因此标题似乎漂浮在列表项上方。
当我们移动画布时,我们还必须考虑另一个接近的标题正在开始将其挤出路的情况。裁剪处理一些关于涉及填充时应该如何查看的角落情况。
我开始修改代码以执行您想要的操作,但事情很快变得复杂起来。
与其跟踪两个标题,不如跟踪三个标题:标题、子标题和接近标题。现在,您必须处理子标题的顶部偏移量以及标题的顶部偏移量。然后有两种情况:首先是接近标题是一个主标题。这将修改两个顶部偏移量。但是当接近标题是标题时,仅影响固定子标题的顶部偏移量,而主标题偏移量保持不变。
我可以做到这一点,但现在时间短缺。如果我能找到时间,我会完成代码并发布它。

就是 dispatchDraw 方法让我很困惑。为了解决这个问题,我尝试注释掉它的所有代码,只保留条件语句(当然,条件语句体也被注释掉了)。然后,我只取消注释了两行代码:canvas.translatemCurrentlyStickingView.draw(在 else 语句中),结果发现实现仍然按预期工作。 - shoe
似乎这两行代码是关键所在,但我不明白它们是如何使粘性视图“粘”在顶部的。你能解释一下这两行代码都在做什么吗?主要是它们如何共同工作,因为只有这两行代码都被取消注释才能实现粘性实现。 - shoe
如果我们不想绘制,只是将translationY移动到正确的位置,以避免在视图上反复绘制画布,该怎么办呢?我正在尝试固定一个视频,而绘制会导致它闪烁。 - Ravin
问题在于ScrollView在滚动期间不断绘制,因此重绘修复了ScrollView对粘性标题栏造成的损坏。这就是为什么你会看到闪烁的原因。视频是特殊情况,因为它通常在自己的线程上使用OpenGL进行绘制。让视频合作可能会很困难。我真的没有答案。如果您还没有,请发布一个新问题,并提供所有细节,以便整个社区都有机会帮助您。 - kris larson

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接