首页 / 资讯中心 / 文章详情

Android处子Demo—用ViewDragHelper与VelocityTracker复刻UC首页动画

Android处子Demo—用ViewDragHelper与VelocityTracker复刻UC首页动画 ★ FEATURED ARTICLE
1. 从 UC 首页动画说起为什么值得用 ViewDragHelper 手写一遍UC 首页那个下拉回弹、搜索栏缩放、底部按钮散开的动画很多人第一次看到都会觉得“这得用多少动画代码堆出来”。其实核心只有两个类ViewDragHelper负责接管子 View 的拖拽VelocityTracker负责判断手指甩出去的速度剩下的都是根据拖拽距离做插值。你把这套链路跑通一次后面做侧滑菜单、卡片拖拽、下拉刷新都会轻松很多。这篇面向 Android 初学者目标很明确搭一个最小可运行的 Demo复刻 UC 首页的下拉回弹与拖拽交互。整体布局抽象成五块——隐藏的新闻导航栏、搜索栏、网站导航、新闻内容区ScrollView、底部导航。拖拽时内容区跟着手指走搜索栏和网站导航按比例缩小底部按钮向两侧散开并淡出松手后根据位移和速度决定是回弹还是吸附到顶部。我会把布局骨架、ViewDragHelper.Callback的关键回调、VelocityTracker的速度阈值判断都拆开讲最后给你一套可以照着验证的动作。代码基于RelativeLayout自定义 ViewGroup不依赖任何第三方库AndroidX 下把android.support.v4换成androidx.customview.widget.ViewDragHelper和androidx.core.view.VelocityTrackerCompat即可。2. 前置准备TaoToken 接入与工程环境如果你打算在 Demo 里接入模型能力做点扩展比如让首页的搜索栏支持自然语言查询可以先把 TaoToken 的 Key 配好。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后在控制台创建 API Key接入地址用 https://taotoken.net/api 。这一步不是本篇动画 Demo 的必需项但如果你想让 Demo 后续能调用模型提前配好会省事。工程环境方面Android Studio 新建一个 Empty Views Activity 项目即可minSdk设 21 以上compileSdk用 34。布局文件放在res/layout/自定义 View 放在和MainActivity同级的包下。依赖只需要 AndroidX 基础库ViewDragHelper在androidx.customview:customview:1.1.0里VelocityTrackerCompat在androidx.core:core:1.12.0里新建项目默认就有。注意ViewDragHelper是官方提供的工具类不是反射或黑科技它在androidx.customview.widget包下源码可读遇到问题直接点进去看continueSettling和settleCapturedViewAt的实现比搜博客快。3. 可复制配置布局骨架与自定义 ViewGroup3.1 五段式布局 activity_main.xml外层用自定义的UCLinear里面按顺序放五个子 View。新闻导航栏初始是隐藏的用TextView占位搜索栏和网站导航用TextView和Button简化新闻内容区用ScrollView包一个TextView底部导航用横向LinearLayout放五个Button。?xml version1.0 encodingutf-8? com.example.ucfirstpage.UCLinear xmlns:androidhttp://schemas.android.com/apk/res/android android:idid/uc_liner android:layout_widthmatch_parent android:layout_heightmatch_parent TextView android:idid/uc_guide android:layout_widthmatch_parent android:layout_height48dp android:background#FF5722 android:gravitycenter android:text新闻导航栏 android:textColor#FFFFFF / TextView android:idid/uc_search android:layout_widthmatch_parent android:layout_height56dp android:background#2196F3 android:gravitycenter android:text搜索栏 android:textColor#FFFFFF / LinearLayout android:idid/uc_webguide android:layout_widthmatch_parent android:layout_height80dp android:background#4CAF50 android:gravitycenter android:orientationhorizontal Button android:layout_widthwrap_content android:layout_heightwrap_content android:text网站1 / Button android:layout_widthwrap_content android:layout_heightwrap_content android:text网站2 / Button android:layout_widthwrap_content android:layout_heightwrap_content android:text网站3 / /LinearLayout ScrollView android:idid/uc_news android:layout_widthmatch_parent android:layout_heightmatch_parent android:background#EEEEEE TextView android:layout_widthmatch_parent android:layout_heightwrap_content android:padding16dp android:text新闻内容区可以上下滚动。\n\n下拉试试回弹效果。\n\n再多写几行方便测试滚动。\n\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10 android:textSize16sp / /ScrollView LinearLayout android:idid/uc_bottom android:layout_widthmatch_parent android:layout_height56dp android:background#FFFFFF android:gravitycenter android:orientationhorizontal Button android:idid/btnPrev android:layout_widthwrap_content android:layout_heightwrap_content android:text上一页 / Button android:idid/btnNext android:layout_widthwrap_content android:layout_heightwrap_content android:text下一页 / Button android:idid/btnMore android:layout_widthwrap_content android:layout_heightwrap_content android:text更多 / Button android:idid/btnPage android:layout_widthwrap_content android:layout_heightwrap_content android:text页面 / Button android:idid/btnToHome android:layout_widthwrap_content android:layout_heightwrap_content android:text首页 / /LinearLayout /com.example.ucfirstpage.UCLinear3.2 自定义 ViewGroup 的初始化与测量UCLinear继承RelativeLayout在构造里创建ViewDragHelper和VelocityTracker。关键点是用OnPreDrawListener在第一次绘制前拿到各子 View 的高度和内容区初始 top然后移除监听避免重复执行。public UCLinear(Context context, AttributeSet attrs) { super(context, attrs); mCallBack new DragHelperCallback(); mDragHelper ViewDragHelper.create(this, mCallBack); this.getViewTreeObserver().addOnPreDrawListener(new OnPreDrawListener() { Override public boolean onPreDraw() { CONTENT_ORI_TOP_LOC mViewContent.getTop(); mGuideHeight mViewGuide.getHeight(); mSearchHeight mViewSearch.getHeight(); mWebGuideHeight mViewWebGuide.getHeight(); mTotalHeight UCLinear.this.getHeight(); mViewGuide.bringToFront(); mViewGuide.setTranslationY(-mGuideHeight); UCLinear.this.getViewTreeObserver().removeOnPreDrawListener(this); return false; } }); }onFinishInflate里绑定五个子 View 和底部按钮onLayout里手动摆放位置。注意内容区的 top 是搜索栏高度加网站导航高度bottom 要减去底部导航高度这样 ScrollView 才有正确的可视区域。Override protected void onLayout(boolean changed, int l, int t, int r, int b) { mViewGuide.layout(0, 0, mViewGuide.getMeasuredWidth(), mViewGuide.getMeasuredHeight()); mViewSearch.layout(0, 0, mViewSearch.getMeasuredWidth(), mViewSearch.getMeasuredHeight()); mViewWebGuide.layout(0, mViewSearch.getMeasuredHeight(), mViewWebGuide.getMeasuredWidth(), mViewSearch.getMeasuredHeight() mViewWebGuide.getMeasuredHeight()); mViewBottom.layout(0, getMeasuredHeight() - mViewBottom.getMeasuredHeight(), mViewBottom.getMeasuredWidth(), getMeasuredHeight()); mViewContent.layout(0, mViewWebGuide.getMeasuredHeight() mViewSearch.getMeasuredHeight(), mViewContent.getMeasuredWidth(), mViewWebGuide.getMeasuredHeight() mViewSearch.getMeasuredHeight() getMeasuredHeight() - mViewGuide.getMeasuredHeight() - mViewBottom.getMeasuredHeight()); }3.3 DragHelperCallback 的三个核心回调tryCaptureView决定谁能被拖这里只允许内容区在isInRange为 true 时被捕获。clampViewPositionVertical限制拖拽范围上边界是导航栏高度下边界是网站导航的 bottom。onViewPositionChanged是动画的灵魂根据内容区 top 的变化量同步更新导航栏位移、搜索栏和网站导航的缩放、底部按钮的位移和透明度。Override public boolean tryCaptureView(View child, int pointerId) { if (mDragHelper.continueSettling(true)) return false; return mViewContent child isInRange; } Override public int clampViewPositionVertical(View child, int top, int dy) { int topBound mGuideHeight; int bottomBound mViewWebGuide.getBottom(); return Math.min(Math.max(top, topBound), bottomBound); } Override public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) { float guidedy dy / (float)(mSearchHeight mWebGuideHeight - mGuideHeight) * mGuideHeight; mViewGuide.setTranslationY(mViewGuide.getTranslationY() - guidedy); float scale (float)((mViewWebGuide.getBottom() - mViewContent.getTop()) / (float)(mSearchHeight mWebGuideHeight - mGuideHeight)); mViewSearch.setPivotY(mViewSearch.getTop()); mViewSearch.setPivotX(getWidth() / 2); mViewSearch.setScaleY(1 - scale / 10); mViewSearch.setScaleX(1 - scale / 10); mViewWebGuide.setPivotY(mViewSearch.getTop()); mViewWebGuide.setPivotX(getWidth() / 2); mViewWebGuide.setScaleY(1 - scale / 10); mViewWebGuide.setScaleX(1 - scale / 10); float alphady (mViewWebGuide.getBottom() - mViewContent.getTop()) / (float)(mSearchHeight mWebGuideHeight - mGuideHeight); btnMore.setAlpha(1 - alphady); float homedy dy / (float)(mSearchHeight mWebGuideHeight - mGuideHeight) * (mViewBottom.getWidth() / 2 - btnToHome.getWidth() / 2); btnToHome.setTranslationX(btnToHome.getTranslationX() homedy); float pagedy dy / (float)(mSearchHeight mWebGuideHeight - mGuideHeight) * (btnPage.getWidth() / 2 btnMore.getWidth() / 2); btnPage.setTranslationX(btnPage.getTranslationX() pagedy); btnPage.setAlpha(1 - alphady); float prevdy dy / (float)(mSearchHeight mWebGuideHeight - mGuideHeight) * (mViewBottom.getWidth() / 2 - btnPrev.getWidth() / 2); btnPrev.setTranslationX(btnPrev.getTranslationX() - prevdy); btnPrev.setAlpha(1 - alphady); float nextdy dy / (float)(mSearchHeight mWebGuideHeight - mGuideHeight) * (btnNext.getWidth() / 2 btnMore.getWidth() / 2); btnNext.setTranslationX(btnNext.getTranslationX() - nextdy); btnNext.setAlpha(1 - alphady); }onViewReleased根据拖拽距离决定吸附位置位移超过网站导航高度就吸附到顶部否则回弹到初始位置。computeScroll里调用continueSettling并触发重绘这是ViewDragHelper平滑动画的标准写法。Override public void onViewReleased(View releasedChild, float xvel, float yvel) { int movelen CONTENT_ORI_TOP_LOC - mViewContent.getTop(); if (movelen mWebGuideHeight) { isInRange false; isOrigin false; mDragHelper.settleCapturedViewAt(0, mGuideHeight); postInvalidate(); } else { mDragHelper.settleCapturedViewAt(0, CONTENT_ORI_TOP_LOC); postInvalidate(); } } Override public void computeScroll() { if (mDragHelper.continueSettling(true)) { ViewCompat.postInvalidateOnAnimation(this); } }3.4 VelocityTracker 的速度阈值判断onInterceptTouchEvent里用VelocityTracker计算 Y 方向速度。向上甩超过 1500 像素/秒且当前在范围内直接平滑滑到顶部向下甩超过 500 像素/秒且内容区已经滚到顶部触发回弹。这两个阈值是实测下来比较跟手的值你可以根据自己设备调整。Override public boolean onInterceptTouchEvent(MotionEvent ev) { if (mVTracker null) { mVTracker VelocityTracker.obtain(); } mVTracker.addMovement(ev); final VelocityTracker vt mVTracker; if (ev.getAction() MotionEvent.ACTION_MOVE) { vt.computeCurrentVelocity(1000); if (vt.getYVelocity() -1500 isInRange) { mDragHelper.smoothSlideViewTo(mViewContent, 0, mGuideHeight); this.postInvalidate(); isInRange false; isOrigin false; return false; } else if (vt.getYVelocity() 500 mViewContent.getScrollY() 0) { this.setBackToOrigin(); return true; } } return mDragHelper.shouldInterceptTouchEvent(ev); } Override public boolean onTouchEvent(MotionEvent event) { mDragHelper.processTouchEvent(event); return false; }4. 验证请求跑起来看动画链路是否完整4.1 编译与运行把上面的布局和两个 Java 文件放进项目MainActivity里绑定UCLinear和首页按钮重写onBackPressed让返回键优先回弹而不是退出。public class MainActivity extends Activity { public UCLinear ucView; public Button btnHome; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ucView findViewById(R.id.uc_liner); btnHome findViewById(R.id.btnToHome); btnHome.setOnClickListener(v - { if (!ucView.isOrigin) { ucView.setBackToOrigin(); } }); } Override public void onBackPressed() { if (!ucView.isOrigin) { ucView.setBackToOrigin(); } else { super.onBackPressed(); } } }运行后你应该看到搜索栏和网站导航在顶部新闻内容区占据中间底部五个按钮横排。手指按住内容区往上拖搜索栏和网站导航按比例缩小底部按钮向两侧散开并淡出松手后如果拖得够远内容区吸附到顶部新闻导航栏从上方滑入如果拖得不够回弹到原位。4.2 三个验证动作第一个动作慢速上拖到一半松手观察是否回弹。这验证onViewReleased里movelen mWebGuideHeight的判断是否生效。第二个动作快速上甩观察是否直接吸附到顶部。这验证VelocityTracker的 -1500 阈值是否触发smoothSlideViewTo。第三个动作在内容区滚到顶部后快速下甩观察是否触发setBackToOrigin。这验证向下 500 阈值和getScrollY() 0的联合判断。提示如果动画卡顿检查computeScroll里是否调用了ViewCompat.postInvalidateOnAnimation漏掉这行会导致continueSettling只执行一次就停住。5. 本篇常见错排查5.1 拖拽没反应或只拖一下就停最常见的原因是tryCaptureView返回了 false。检查mViewContent child是否成立以及isInRange是否被意外置为 false。另一个原因是onTouchEvent没有调用mDragHelper.processTouchEvent(event)或者返回了 true 导致事件被消费掉。正确写法是调用后返回 false让ViewDragHelper自己处理。5.2 松手后不回弹或吸附位置不对CONTENT_ORI_TOP_LOC是在OnPreDrawListener里取的如果取的时候布局还没完成值会是 0。确保removeOnPreDrawListener在取完值之后调用并且onLayout里内容区的 top 计算正确。另外settleCapturedViewAt的目标坐标是相对父容器的不是相对内容区自身。5.3 ScrollView 和拖拽冲突这是原版代码里也提到的问题ScrollView 自己会消费上下滑动事件导致ViewDragHelper收不到。解决办法是在onInterceptTouchEvent里优先判断速度阈值满足条件时直接返回 true 拦截事件不让 ScrollView 处理。如果还是冲突可以自定义 ScrollView 重写onTouchEvent在内容区 top 不等于初始位置时返回 false。5.4 底部按钮散开后回不来setBackToOrigin里对每个按钮做了TranslateAnimation并设置setFillAfter(true)动画结束后要手动把translationX归零并clearAnimation否则下次拖拽时位移会叠加。检查每个按钮的onAnimationEnd里是否都有这两步。6. 继续往下走把 Demo 变成可复用的手势组件这套 Demo 跑通后你已经掌握了ViewDragHelper接管拖拽、VelocityTracker判断速度、onViewPositionChanged做联动动画的完整链路。下一步可以尝试把五段式布局抽成自定义属性让导航栏高度、缩放比例、速度阈值都能在 XML 里配置也可以把setBackToOrigin里的重复动画代码抽成一个方法减少样板。如果你想让 Demo 接入模型能力比如搜索栏输入自然语言后调用接口返回结果可以在 TaoToken 控制台创建 API Key接入文档在 https://taotoken.net/api-keys 模型对话调试用 https://taotoken.net/models 。长期做编码和 Agent 类项目的话Coding Plan 在 https://taotoken.net/coding-plan 。这些链接按需取用动画部分本身不依赖它们。最后留一个我踩过的坑ViewDragHelper的continueSettling在tryCaptureView里调用时如果返回 true 说明还在动画中此时应该拒绝新的捕获否则会出现拖拽和吸附动画打架的情况。原版代码里这行判断很关键别删。
阅读完成 · 觉得有帮助?
咨询建站