Android 高仿美团外卖详情页

作者: ziwenl | 来源:发表于2020-11-25 09:10 被阅读0次

    1.需求分析

    美团外卖详情页

    需求特点

    • 多重嵌套滚动
    • 标题栏 内容跟随滚动变化
    • 店铺信息(店铺名、描述、评分、优惠信息、公告等)滚动时 折叠隐藏完全展开
    • “点菜” 、“评价” 及 “商家” 栏滚动时 悬停吸顶
    • “点菜” 页面内,列表悬停 效果及菜品 列表 Item 吸顶过渡替换
    • 底部满减神器、满减优惠、选中价格等内容 随店铺信息展开渐变隐藏

      从需求特点来看,这些功能都是比较常见的功能,普遍对应的解决方案如下

    功能分析

    • 多重嵌套滚动:
        1.事件分发
        2.NestedScroll嵌套滑动机制
        3.CoordinatorLayout 与 Behavior 配合实现
    • 内容跟随滚动变化
        通过监听滚动事件,配合属性动画实现
    • 悬停吸顶
        1.绘制两个相同的 View,AView随布局滚动,BView固定在布局某处,再根据滚动距离,动态隐藏或显示 BView,造成吸顶假象
        2.CoordinatorLayout 与 Behavior 配合实现
    • 列表 Item 吸顶过渡替换:
         通过自定义 RecyclerView.Decoration 实现

    2.具体实现

    2.1效果展示

    仿美团外卖详情页

    主要通过 CoordinatorLayout + 自定义 Behavior 的方式实现

    CoordinatorLayout :

    官方文档描述

    CoordinatorLayout 是功能更强大的 FrameLayout
    CoordinatorLayout 适用于两个主要用例:
      作为顶层应用程序装饰或 chrome 布局
      作为与一个或多个子视图进行特定交互的容器
      通过指定 BehaviorsCoordinatorLayout 的子视图,您可以在单个父视图中提供许多不同的交互,并且这些视图也可以彼此交互。当视图类用作带 CoordinatorLayout.DefaultBehavior 注释的 CoordinatorLayout 的子级时,可以指定默认行为 。

    CoordinatorLayout.Behavior:Behavior 是 CoordinatorLayout 的一个抽象内部类

    官方文档描述

      互动行为插件,用在位于 CoordinatorLayout 中的子视图上
      行为实现了用户可以在子视图上进行的一个或多个交互。这些交互可能包括拖动,滑动,甩动或任何其他手势

      主要是通过为 CoordinatorLayout 设置 CoordinatorLayout.Behavior ,在 CoordinatorLayout.Behavior 的一系列回调方法中,操作 CoordinatorLayout 中包含的子 View ,实现想要的交互效果

    • 为 CoordinatorLayout 设置 Behavior(有三种方式):
        1.在 xml 布局通过 app:layout_behavior 来指定
        2.在代码中,通过 child.getLayoutParams().setBehavior() 来指定
        3.在目标 childView 类上,通过 @DefaultBehavior 来指定
        (本文采用最常用的方式1进行设置)
    • Behavior<V> 包含的主要方法有:
        // 确定使用 Behavior 的 View 位置
        onLayoutChild ()
        // 确定使用 Behavior 的 View 要依赖的 View ,可以在此处得到 CoordinatorLayout 下的其它子 View
        layoutDependsOn ()
        // 当被依赖的 View 状态改变时回调
        onDependentViewChanged ()
        // 嵌套滑动开始,确定 Behavior 是否要监听此次事件
        onStartNestedScroll ()
        // 嵌套滑动进行中,要监听的子 View 将要滑动,滑动事件即将被消费(但最终被谁消费,可以通过代码控制)
        onNestedPreScroll ()
        // 接受嵌套滚动
        onNestedScrollAccepted ()
        // 要监听的子View即将惯性滑动(开始非实际触摸的惯性滑动)
        onNestedPreFling ()
        // 嵌套滑动结束
        onStopNestedScroll ()
    • 与滚动动作相关的方法回调中,都有一个 @NestedScrollType type : Int 参数,像 onStartNestedScroll ()onNestedPreScroll ()onNestedScrollAccepted ()onStopNestedScroll () 等,该 type 是用来区分当前的滚动是由实际触摸引起的,还是由触摸结束后的惯性引起的。其中:
        type = ViewCompat.TYPE_TOUCH 时,表示滚动是由实际触摸引起的(正在触摸中)
        type = ViewCompat.TYPE_NON_TOUCH 时,表示滚动是由惯性引起的(触摸已经结束,甩动动作带动的滑动)

      嵌套滚动两种情况下( 抬起时无甩动动作抬起时有甩动动作 ),滚动相关回调方法触发顺序

    无甩动动作时,滚动相关方法调用顺序示意图-1
    有甩动动作时,滚动相关方法调用顺序示意图-2

      手指抬起时有甩动动作引起的惯性嵌套滚动,是在执行完实际触摸引起的嵌套滚动后执行的。也就是上面示意图1执行完之后才会执行示意图2。

      强调上述内容,是为了更好的处理手指快速滑动时,CoordinatorLayout 内的子 View 交互

    2.2布局分析

    布局分析
      XML代码如下,将各部分分别抽成成一个 View(点击跳转查看源码:activity_shop_details.xmlShopDiscountLayoutShopContentLayoutShopTitleLayoutShopPriceLayout
    <?xml version="1.0" encoding="utf-8"?>
    <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:id="@+id/cl_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@android:color/white">
    
        <!--顶部 店铺信息+优惠活动 内容-->
        <com.ziwenl.meituan_detail.ui.shop.ShopDiscountLayout
            android:id="@+id/layout_discount"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    
        <!--中下部 点菜/评论/商家 内容-->
        <com.ziwenl.meituan_detail.ui.shop.ShopContentLayout
            android:id="@+id/layout_main"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:layout_marginTop="@dimen/top_min_height"
            app:layout_behavior=".ui.shop.ShopContentBehavior" />
    
        <!--顶部 标题栏 内容-->
        <com.ziwenl.meituan_detail.ui.shop.ShopTitleLayout
            android:id="@+id/layout_title"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    
        <!--底部 满减神器、满减优惠、价格费用-->
        <com.ziwenl.meituan_detail.ui.shop.ShopPriceLayout
            android:id="@+id/layout_price"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />
    
    </androidx.coordinatorlayout.widget.CoordinatorLayout>
    

    2.3代码分析

    2.3.1自定义 CoordinatorLayout.Behavior

      自定义 ShopContentBehavior (点击查看源码) 继承于 CoordinatorLayout.Behavior ,并将 ShopContentLayout 视图设置为其使用者

    class ShopContentBehavior(private val context: Context, attrs: AttributeSet?) :
        CoordinatorLayout.Behavior<ShopContentLayout>(context, attrs) {
        ......
    }
    

      声明 xml 布局中 CoordinatorLayout 内需要根据滚动进行交互的子 view,并分别在 onLayoutChild、layoutDependsOn 方法中得到它们的实例

        /**
         * 顶部标题栏:返回、搜索、收藏、更多
         */
        private lateinit var mShopTitleLayoutView: ShopTitleLayout
    
        /**
         * 中上部分店铺信息:配送时间、描述、评分、优惠及公告
         */
        private lateinit var mShopDiscountLayoutView: ShopDiscountLayout
    
        /**
         * 中下部分:点菜(广告、菜单)、评价、商家
         */
        private lateinit var mShopContentLayoutView: ShopContentLayout
    
        /**
         * 底部价格:满减神器、满减优惠、选中价格
         */
        private lateinit var mShopPriceLayoutView: ShopPriceLayout
    
        override fun onLayoutChild(
            parent: CoordinatorLayout,
            child: ShopContentLayout,
            layoutDirection: Int
        ): Boolean {
            if (!this::mShopContentLayoutView.isInitialized) {
                mShopContentLayoutView = child
                ......
            }
            return super.onLayoutChild(parent, child, layoutDirection)
        }
    
        override fun layoutDependsOn(
            parent: CoordinatorLayout,
            child: ShopContentLayout,
            dependency: View
        ): Boolean {
            when (dependency.id) {
                R.id.layout_title -> mShopTitleLayoutView = dependency as ShopTitleLayout
                R.id.layout_discount -> mShopDiscountLayoutView = dependency as ShopDiscountLayout
                R.id.layout_price -> mShopPriceLayoutView = dependency as ShopPriceLayout
                else -> return false
            }
            return true
        }
    

      解决嵌套滚动冲突问题:在 onNestedPreScroll 方法中,根据子 View 是否可以滚动的回调方法判断是否为内部 View 设置偏移
      实现滚动过程中,各部分子 View 随着滚动程度进行相应变化:主要是在 onNestedPreScroll 方法中,根据滚动距离对内部 View 设置属性(透明度、偏移量、缩放等),实现嵌套滚动交互效果,配合工具类 ViewState(点击查看源码) 实现(记录 View 的起始状态和目标状态及对应状态下的属性,再根据滚动进度动态设置目标 View 的相关属性,达到指定 View 样式随滚动程度变化的目的)

        /**
         * 嵌套滑动进行中,要监听的子 View 将要滑动,滑动事件即将被消费(但最终被谁消费,可以通过代码控制)
         * @param type = ViewCompat.TYPE_TOUCH 表示是触摸引起的滚动 = ViewCompat.TYPE_NON_TOUCH 表示是触摸后的惯性引起的滚动
         */
        override fun onNestedPreScroll(
            coordinatorLayout: CoordinatorLayout,
            child: ShopContentLayout,
            target: View,
            dx: Int,
            dy: Int,
            consumed: IntArray,
            type: Int
        ) {
            if (mIsScrollToHideFood) {
                consumed[1] = dy
                return // scroller 滑动中.. do nothing
            }
            mVerticalPagingTouch += dy
            if (mVpMain.isScrollable && abs(mVerticalPagingTouch) > mPagingTouchSlop) {
                mVpMain.isScrollable = false // 屏蔽 pager横向滑动干扰
            }
    
            if (type == ViewCompat.TYPE_NON_TOUCH && mIsFlingAndDown) {
                //当处于惯性滑动时,有触摸动作进入,屏蔽惯性滑动,以防止滚动错乱
                consumed[1] = dy
                return
            }
            if (type == ViewCompat.TYPE_NON_TOUCH) {
                mIsScrollToFullFood = true
            }
            mHorizontalPagingTouch += dx
            if ((child.translationY < 0 || (child.translationY == 0F && dy > 0))
                && !child.getScrollableView().canScrollVertically(-1)
            ) {
                val effect = mShopTitleLayoutView.effectByOffset(dy)
                val transY = -mSimpleTopDistance * effect
                mShopDiscountLayoutView.translationY = transY
                if (transY != child.translationY) {
                    child.translationY = transY
                    consumed[1] = dy
                }
    
            } else if ((child.translationY > 0 || (child.translationY == 0F && dy < 0))
                && !child.getScrollableView().canScrollVertically(-1)
            ) {
                if (mIsScrollToFullFood) {
                    child.translationY = 0F
                } else {
                    child.translationY -= dy
                    mShopDiscountLayoutView.effectByOffset(child.translationY)
                    mShopPriceLayoutView.effectByOffset(child.translationY)
                }
                consumed[1] = dy
            } else {
                //折叠状态
                if (child.getRootScrollView() != null
                    //这个判断是防止按着bannerView滚动时导致scrollView滚动速度翻倍
                    && (child.getScrollableView() is RecyclerView)
                ) {
                    if (dy > 0) {
                        child.getRootScrollView()!!.scrollY += dy
                    }
                }
            }
        }
    

      实现点击指定 View 展开/收缩布局:同样是通过工具类 ViewState(点击查看源码)内的拓展函数 Any?.statesChangeByAnimation () 借由属性动画去更新指定 View 的属性

    // ViewState 内提供的拓展函数
    /**
     * 通过属性动画更新指定 View 状态
     */
    fun Any?.statesChangeByAnimation(
        views: Array<View>,
        startTag: Int,
        endTag: Int,
        start: Float = 0F,
        end: Float = 1F,
        updateCallback: AnimationUpdateListener? = null,
        updateStateListener: AnimatorListenerAdapter? = null,
        duration: Long = 400L,
        startDelay: Long = 0L
    ): ValueAnimator {
        return ValueAnimator.ofFloat(start, end).apply {
            this.startDelay = startDelay
            this.duration = duration
            interpolator = AccelerateDecelerateInterpolator()
            addUpdateListener { animation ->
                val p = animation.animatedValue as Float
                updateCallback?.onAnimationUpdate(startTag, endTag, p)
                for (it in views) it.stateRefresh(startTag, endTag, animation.animatedValue as Float)
            }
            updateStateListener?.let { addListener(it) }
            start()
        }
    }
    
    // ShopDiscountLayout 中点击展开和收缩时的调用示例
    /**
     * 展开/收缩当前布局
     */
    fun switch(
        expanded: Boolean,
        byScrollerSlide: Boolean = false
    ) {
        if (mIsExpanded == expanded) {
            return
        }
        sv_main.scrollTo(0, 0)
        mIsExpanded = expanded // 目标
        val start = effected
        val end = if (expanded) 1F else 0F
        statesChangeByAnimation(
            animViews(), R.id.viewStateStart, R.id.viewStateEnd, start, end,
            null, if (!byScrollerSlide) internalAnimListener else null, 500
        )
    }
    

    2.3.2自定义 RecyclerView.ItemDecoration

      通过自定义 RecyclerView.ItemDecoration 实现列表 Item 吸顶过渡替换效果(点击跳转查看源码)

    3.最后

      要通过 CoordinatorLayout + 自定义 Behavior 实现多重嵌套滚动交互效果,主要还是要了解自定义 Behavior 中嵌套滚动时触发的相关方法的具体调用时机和作用,然后通过为子 View 去设置相关 View 属性,从而实现滚动交互效果。该 Demo 都是业务代码,也没什么需要细讲的地方,具体实现可参考查阅源码。

    相关文章

      网友评论

        本文标题:Android 高仿美团外卖详情页

        本文链接:https://www.haomeiwen.com/subject/cqugiktx.html