前言
在之前的 viewgroup 的事件相关一文中,我们详细的讲解了一些常见的 viewgroup 需要处理的事件与运动的方式。
我们了解了如何处理拦截事件,如何滚动,如何处理子 view 的协调运动等。
再复杂一点,我们可以组合在一起使用。例如在拦截事件之后滚动,或者在滚动到一个阈值之后拦截事件。
今天我们一起再巩固一下相关的知识点,以比较常见的一个应用场景,右滑进入详情的场景为例子。
这个例子中又分几种常见的类型,以几个头部app为例的话:
1. 一种是类似抖音列表的的右滑直接详情:
2. 一种是类似闲鱼这种右滑提示再进入详情
3. 另一种是类似豆瓣这种列表滑动进入详情
接下来我们就一起复习一下,看看都能怎么实现。
话不多说,let's go
一、抖音直接右滑进入详情
其实抖音的这种效果实现的方式有很多,比如 viewpager 是最简单的 ,但是抖音的首页本身就是一个垂直的 viewpager(rv) ,内部的 item 再用横向的 viewpager 做内容与详情的切换?要这么做吗?能不能这么做?
能当然能,但是呢,没必要。
一般这种简单的效果,我们一般使用自定义 viewgroup 即可实现轻量的效果,不需要整那么“笨重” 。
那么自定义 viewgroup 如何实现这种效果呢? 总归是记录点击坐标,记录移动坐标,然后对对应的子view做移动,例如 translationx 、scroller 都可以完成类似的逻辑,在放开的时候滚动回指定的位置即可。
确实,这样是标准的做法,也不是不行,但是我们这个效果并不涉及到事件的拦截与一些处理,其实我们可以使用更简单的方式 viewdraghelper 来实现,它内部集成了移动事件的判断与移动的逻辑封装,还能让子view协调运动,也是特别适合这个场景。
如何使用呢?代码如下:
public class douyinview5 extends framelayout { private view contentview; private view detailview; private int contentwidth; private int contentheight; private int detailwidth; private int detailheight; private viewdraghelper viewdraghelper; private float downx; private float downy; public douyinview5(context context) { super(context); init(); } public douyinview5(context context, attributeset attrs) { super(context, attrs); init(); } public douyinview5(context context, attributeset attrs, int defstyleattr) { super(context, attrs, defstyleattr); init(); } private void init() { viewdraghelper = viewdraghelper.create(this, callback); } @override public boolean onintercepttouchevent(motionevent ev) { return viewdraghelper.shouldintercepttouchevent(ev); } @override public boolean ontouchevent(motionevent event) { switch (event.getaction()) { case motionevent.action_down: downx = event.getx(); downy = event.gety(); break; case motionevent.action_move: float movex = event.getx(); float movey = event.gety(); float dx = movex - downx; float dy = movey - downy; if (math.abs(dx) > math.abs(dy)) { requestdisallowintercepttouchevent(true); } downx = movex; downy = movey; break; case motionevent.action_up: break; } viewdraghelper.processtouchevent(event); return true; } //完成初始化,获取控件 @override protected void onfinishinflate() { super.onfinishinflate(); contentview = getchildat(0); detailview = getchildat(1); } /** * 完成测量时调用,获取高度,宽度 */ @override protected void onsizechanged(int w, int h, int oldw, int oldh) { super.onsizechanged(w, h, oldw, oldh); contentwidth = contentview.getmeasuredwidth(); contentheight = contentview.getmeasuredheight(); detailwidth = detailview.getmeasuredwidth(); detailheight = detailview.getmeasuredheight(); } /** * 调用方法完成位置的布局 */ @override protected void onlayout(boolean changed, int left, int top, int right, int bottom) { contentview.layout(0, 0, contentwidth, contentheight); detailview.layout(contentwidth, 0, contentwidth + detailwidth, detailheight); } private viewdraghelper.callback callback = new viewdraghelper.callback() { @override public boolean trycaptureview(view child, int pointerid) { return child == contentview || child == detailview; } @override public int getviewhorizontaldragrange(view child) { return detailwidth; } @override public int clampviewpositionhorizontal(view child, int left, int dx) { //边界的限制 if (child == contentview) { if (left > 0) left = 0; if (left < -detailwidth) left = -detailwidth; } else if (child == detailview) { if (left > contentwidth) left = contentwidth; if (left < contentwidth - detailwidth) left = contentwidth - detailwidth; } return left; } @override public void onviewpositionchanged(view changedview, int left, int top, int dx, int dy) { super.onviewpositionchanged(changedview, left, top, dx, dy); //做内容布局移动的时候,详情布局跟着同样的移动 if (changedview == contentview) { detailview.layout(detailview.getleft() + dx, detailview.gettop() + dy, detailview.getright() + dx, detailview.getbottom() + dy); } else if (changedview == detailview) { //当详情布局移动的时候,内容布局做同样的移动 contentview.layout(contentview.getleft() + dx, contentview.gettop() + dy, contentview.getright() + dx, contentview.getbottom() + dy); } } @override public void onviewreleased(view releasedchild, float xvel, float yvel) { super.onviewreleased(releasedchild, xvel, yvel); //松开之后,只要移动超过一半就可以打开或者关闭 if (contentview.getleft() < -detailwidth / 2) { open(); } else { close(); } } }; public void open() { viewdraghelper.smoothslideviewto(contentview, -detailwidth, 0); viewcompat.postinvalidateonanimation(this); } public void close() { viewdraghelper.smoothslideviewto(contentview, 0, 0); viewcompat.postinvalidateonanimation(this); } @override public void computescroll() { if (viewdraghelper.continuesettling(true)) { viewcompat.postinvalidateonanimation(this); } } }
除去测量布局的代码(继承了framlayout,不需要我们自己手动测量了),再除去 viewdraghelper 的模板代码。核心代码就那么10多行。
这样即可实现简单的效果了:
是不是很简单!
而有些同学可能会说 viewdraghelper 好麻烦,我还需要在移动的时候处理事件呢,也不方便用 viewdraghelper ,能不能使用基本的方式来实现呢?
二、闲鱼右滑进入详情
确实,如果内部有多个view ,还涉及到一些事件的拦截与处理,我们可以使用基本的 motionevent 来判断。
这里以闲鱼的右滑进入详情为例子,我们需要在滑动的时候记录移动值,然后让右侧的滑块绘制对应的贝塞尔背景,并且这个 textview 还是竖直排列文本的,所以我们需要先自定义一个这个特殊的 textview 。
完整的代码如下:
/** * 右侧的查看滑动更多,竖版排列文本效果,并绘制贝塞尔曲线背景 */ public class showmoretextview extends appcompattextview { // 默认文本 private charsequence mdefaulttext = "更多"; //默认使用文本画笔 protected textpaint mtextpaint; //每个文字的间距 private int mcharspacing; // 贝塞尔阴影画笔 private paint mshadowpaint; // 贝塞尔的路径 private path mshadowpath; //贝塞尔曲线的控制点-变量动态控制 private float mshadowoffset = 0; //默认的间距 private int mnormalspaceing; public showmoretextview(context context) { this(context, null); } public showmoretextview(context context, @nullable attributeset attrs) { this(context, attrs, 0); } public showmoretextview(context context, @nullable attributeset attrs, int defstyleattr) { super(context, attrs, defstyleattr); //画笔的一些配置 mtextpaint = new textpaint(paint.anti_alias_flag); mtextpaint.setantialias(true); //默认间距 mcharspacing = commutils.dip2px(4); mnormalspaceing = commutils.dip2px(8); //画笔赋值 mshadowpaint = new paint(); mshadowpaint.setcolor(color.parsecolor("#4fcccccc")); mshadowpaint.setantialias(true); mshadowpaint.setstyle(paint.style.fill); mshadowpaint.setstrokewidth(1); mshadowpath = new path(); } @override protected void ondraw(canvas canvas) { super.ondraw(canvas); mtextpaint.settextsize(gettextsize()); mtextpaint.setcolor(getcurrenttextcolor()); mtextpaint.settypeface(gettypeface()); //竖版文本的绘制 charsequence text = mdefaulttext; if (text != null && !text.tostring().trim().equals("")) { rect bounds = new rect(); mtextpaint.gettextbounds(text.tostring(), 0, text.length(), bounds); float startx = getlayout().getlineleft(0) + getpaddingleft(); //处理drawleft的间距 if (getcompounddrawables()[0] != null) { rect drawrect = getcompounddrawables()[0].getbounds(); startx += (drawrect.right - drawrect.left); } startx += getcompounddrawablepadding(); float starty = getbaseline(); //不处理bounds会导致间距异常 int cheight = (bounds.bottom - bounds.top + mcharspacing); // 居中水平对齐 starty -= (text.length() - 1) * cheight / 2; for (int i = 0; i < text.length(); i++) { string c = string.valueof(text.charat(i)); canvas.drawtext(c, startx, starty + i * cheight, mtextpaint); } } // 动态的绘制贝塞尔的背景 mshadowpath.reset(); mshadowpath.moveto(getwidth(), 0); mshadowpath.quadto(mshadowoffset, getheight() / 2, getwidth(), getheight()); canvas.drawpath(mshadowpath, mshadowpaint); } @override public void settext(charsequence text, buffertype type) { mdefaulttext = text; super.settext("", type); } public void setverticaltext(charsequence text) { if (textutils.isempty(text)) return; mdefaulttext = text; invalidate(); } /** * 暴露的方法,控制贝塞尔曲线的控制点 */ public void setshadowoffset(float offset, float maxoffset) { this.mshadowoffset = offset; float dis = maxoffset / 2 - mnormalspaceing; if (mshadowoffset >= dis) { mshadowoffset = dis; } else { mshadowoffset = dis + (offset - dis) / 2; } invalidate(); } }
主要是基于变量 mshadowoffset 来绘制贝塞尔背景,然后就是其中绘制文本的一些控制了。
而我们主要的容器则是继承自 viewgroup , 之前是继承了framelayout ,不需要我们测量,现在测量布局都需要我们自己来了。
在我们之前的文章中,我们都已经反复的复习过了,这里就快速跳过这些非重点代码:
//完成初始化,获取控件 @override protected void onfinishinflate() { super.onfinishinflate(); mcontentview = getchildat(0); mmoretextview = (showmoretextview) getchildat(1); } @override protected void onsizechanged(int w, int h, int oldw, int oldh) { super.onsizechanged(w, h, oldw, oldh); contentwidth = mcontentview.getmeasuredwidth(); contentheight = mcontentview.getmeasuredheight(); showmoreviewwidth = mmoretextview.getmeasuredwidth(); showmoreviewheight = mmoretextview.getmeasuredheight(); //右侧布局的偏移量 moffsetwidth = -showmoreviewwidth; } @override protected void onmeasure(int widthmeasurespec, int heightmeasurespec) { //测量真正的容器的布局 measurechild(mcontentview, widthmeasurespec, heightmeasurespec); //测量showmore布局 measurechild(mmoretextview, widthmeasurespec, heightmeasurespec); this.setmeasureddimension(mcontentview.getmeasuredwidth(), mcontentview.getmeasuredheight()); } @override protected void onlayout(boolean changed, int left, int top, int right, int bottom) { mcontentview.layout(0, 0, contentwidth, contentheight); mmoretextview.layout(contentwidth, contentheight / 2 - showmoreviewheight / 2, contentwidth + showmoreviewwidth, contentheight / 2 - showmoreviewheight / 2 + showmoreviewheight); }
接下来就是记录坐标点,移动的坐标点,以及取消事件的动画,基本上可以认为是一套模板代码,可以套用到类似的效果上。
@override public boolean ontouchevent(motionevent ev) { switch (ev.getaction()) { case motionevent.action_down: mhintleftmargin = 0; mlastx = ev.getrawx(); mlasty = ev.getrawy(); break; case motionevent.action_move: // 释放动画 if (mreleasedanim != null && mreleasedanim.isrunning()) { break; } mdeltax = (ev.getrawx() - mlastx); mdeltay = ev.getrawy() - mlasty; mlastx = ev.getrawx(); mlasty = ev.getrawy(); mdeltax = mdeltax * ratio; //滑动的赋值 if (mdeltax > 0) { // 右滑 sethinttexttranslationx(mdeltax); } else if (mdeltax < 0) { // 左滑 sethinttexttranslationx(mdeltax); } break; case motionevent.action_cancel: case motionevent.action_up: //拦截事件-父布局滚 getparent().requestdisallowintercepttouchevent(false); // 释放动画 if (mreleasedanim != null && mreleasedanim.isrunning()) { break; } //如果达到指定位置了才算释放 if (moffsetwidth != 0 && mhintleftmargin <= moffsetwidth && mlistener != null) { mlistener.onrelease(); } //默认的回去动画 mreleasedanim = valueanimator.offloat(1.0f, 0); mreleasedanim.setduration(300); mreleasedanim.addupdatelistener(new valueanimator.animatorupdatelistener() { @override public void onanimationupdate(valueanimator animation) { float value = (float) animation.getanimatedvalue(); mmoretextview.settranslationx(value * mmoretextview.gettranslationx()); } }); mreleasedanim.start(); break; } return true; } /** * 设置showmore布局的偏移量,并且设置内部重绘贝塞尔曲线的控制点变量 */ private void sethinttexttranslationx(float deltax) { float offsetx = 0; if (mmoretextview != null) { mhintleftmargin += deltax; if (mhintleftmargin <= moffsetwidth) { offsetx = moffsetwidth; mmoretextview.setverticaltext(release_more); } else { offsetx = mhintleftmargin; mmoretextview.setverticaltext(scroll_more); } mmoretextview.setshadowoffset(offsetx, moffsetwidth); mmoretextview.settranslationx(offsetx); yylogutils.w("settranslationx:" + offsetx); } }
核心的逻辑是拿到了移动变量之后设置右侧的 showmoreview 的 settranslationx 与它内部的 mshadowoffset 变量,从而达到绘制贝塞尔背景的效果。
这里我们的移动是使用的 settranslationx ,取消事件使用的是属性动画的方式,当然了使用其他方式例如,我们移动都交给 scroller 来完成也是可以的。
效果:
同样的效果,其实我们甚至可以直接使用 viewdraghelper 来实现更为简单,怎么说了,为了下面的例子扩展,我们先选择使用 motionevent + settranslationx 的方式实现,如果有兴趣,大家可以自行使用不同的方式来实现,接下来就是看如何在滚动的列表中加入右滑进入详情的逻辑了。
三、列表的右滑进入详情
如果说之前的效果都可以用 viewdraghelper 来简化完成,那么这种带列表的滚动我们还是最好自己来处理事件与移动与拦截。
对比来说,唯一麻烦的就是我们需要在左侧的rv滚动的时候去及时的处理拦截事件。移动的也好处理,我们可以直接设置左侧rv的 translationx 移动 和 右侧showmoreview 的 translationx 移动。这样就能达到移动的效果。
在我们之前的例子基础上实现,还是基于 settranslationx 来移动,并且使用属性动画来做释放的逻辑,我们再之前的代码上修改一番。
首先我们的布局应该是如下的:
showmoretextview 我们已经很了解了,他就两个功能,第一个就是垂直的文本排列,第二个就是通过一个入参变量控制贝塞尔曲线的控制点。为了简单我就直接使用上一个效果的view了。
此效果的重点就是如何自定义 viewgroup ,处理对应的排列,移动,与事件拦截。
首先一个 viewgroup 需要先完成的就是测量与布局:
public class viewgroup5 extends viewgroup { private recyclerview mhorizontalrecyclerview; private showmoretextview mmoretextview; private int rvcontentwidth; private int rvcontentheight; private int showmoreviewwidth; private int showmoreviewheight; //展示之后获取宽高信息 @override protected void onsizechanged(int w, int h, int oldw, int oldh) { super.onsizechanged(w, h, oldw, oldh); rvcontentwidth = mhorizontalrecyclerview.getmeasuredwidth(); rvcontentheight = mhorizontalrecyclerview.getmeasuredheight(); showmoreviewwidth = mmoretextview.getmeasuredwidth(); showmoreviewheight = rvcontentheight; //右侧布局的偏移量 moffsetwidth = -showmoreviewwidth; } //完成初始化,获取控件 @override protected void onfinishinflate() { super.onfinishinflate(); mhorizontalrecyclerview = (recyclerview) getchildat(0); mmoretextview = (showmoretextview) getchildat(1); } @override protected void onmeasure(int widthmeasurespec, int heightmeasurespec) { //rv测量 - 默认的测量不改动 measurechild(mhorizontalrecyclerview, widthmeasurespec, heightmeasurespec); int width = mhorizontalrecyclerview.getmeasuredwidth(); int height = mhorizontalrecyclerview.getmeasuredheight(); //右侧showmore的测量 - 自行改动高度测量 final layoutparams lp = mmoretextview.getlayoutparams(); mmoretextview.measure( getchildmeasurespec(widthmeasurespec, mmoretextview.getpaddingleft() + mmoretextview.getpaddingright(), lp.width), getchildmeasurespec(measurespec.exactly, mmoretextview.getpaddingtop() + mmoretextview.getpaddingbottom(), height) ); //指定viewgroup的测量 - 父布局的测量就是rv的宽高 this.setmeasureddimension(width, height); } @override protected void onlayout(boolean changed, int left, int top, int right, int bottom) { mhorizontalrecyclerview.layout(0, 0, rvcontentwidth, rvcontentheight); mmoretextview.layout(mhorizontalrecyclerview.getright(), 0, mhorizontalrecyclerview.getright() + showmoreviewwidth, showmoreviewheight); } }
在之前的文章中,我们反复的复习过测量与布局,这里就一笔带过,接下来就是事件的处理与移动。并且在 viewgroup 分发事件,判断是否拦截事件。
- 当滑动到最左侧的时候我们可以继续滑动,给内部的两个布局设置 settranslationx 从而达到移动的效果。
- 当滑动到最右侧的时候,我们同样可以继续滑动,但是内部的方法就可以判断设置 setshadowoffset 去设置贝塞尔曲线的显示。
- 当滑动到中间的时候,我们不拦截事件,我们把事件给rv,所以当前滚动的是rv 控件。
具体实现如下:
@override public boolean dispatchtouchevent(motionevent ev) { if (mhorizontalrecyclerview == null) { return super.dispatchtouchevent(ev); } switch (ev.getaction()) { case motionevent.action_down: mhintleftmargin = 0; mmoveindex = 0; mlastx = ev.getrawx(); mlasty = ev.getrawy(); break; case motionevent.action_move: // 释放动画 if (mreleasedanim != null && mreleasedanim.isrunning()) { break; } float mdeltax = (ev.getrawx() - mlastx); float mdeltay = ev.getrawy() - mlasty; mlastx = ev.getrawx(); mlasty = ev.getrawy(); mdeltax = mdeltax * ratio; //滑动的赋值 if (mdeltax > 0) { // 右滑并判断是否滑动到边缘 if (!mhorizontalrecyclerview.canscrollhorizontally(-1) || mhorizontalrecyclerview.gettranslationx() < 0) { //偏移值加上已偏移的值 float transx = mdeltax + mhorizontalrecyclerview.gettranslationx(); if (mhorizontalrecyclerview.canscrollhorizontally(-1) && transx >= 0) { transx = 0; } //rv和showmore一起设置-translationx mhorizontalrecyclerview.settranslationx(transx); sethinttexttranslationx(mdeltax); } } else if (mdeltax < 0) { // 左滑并判断是否滑动到边缘 if (!mhorizontalrecyclerview.canscrollhorizontally(1) || mhorizontalrecyclerview.gettranslationx() > 0) { //偏移值加上已偏移的值 float transx = mdeltax + mhorizontalrecyclerview.gettranslationx(); if (transx <= 0 && mhorizontalrecyclerview.canscrollhorizontally(1)) { transx = 0; } //rv和showmore一起设置-translationx mhorizontalrecyclerview.settranslationx(transx); sethinttexttranslationx(mdeltax); } } break; case motionevent.action_cancel: case motionevent.action_up: //拦截事件-父布局滚 getparent().requestdisallowintercepttouchevent(false); // 释放动画 if (mreleasedanim != null && mreleasedanim.isrunning()) { break; } //如果达到指定位置了才算释放 if (moffsetwidth != 0 && mhintleftmargin <= moffsetwidth && mlistener != null) { mlistener.onrelease(); } //默认的回去动画 mreleasedanim = valueanimator.offloat(1.0f, 0); mreleasedanim.setduration(300); mreleasedanim.addupdatelistener(new valueanimator.animatorupdatelistener() { @override public void onanimationupdate(valueanimator animation) { float value = (float) animation.getanimatedvalue(); mhorizontalrecyclerview.settranslationx(value * mhorizontalrecyclerview.gettranslationx()); mmoretextview.settranslationx(value * mmoretextview.gettranslationx()); } }); mreleasedanim.start(); break; } return mhorizontalrecyclerview.gettranslationx() != 0 ? true : super.dispatchtouchevent(ev); } /** * 设置showmore布局的偏移量,并且设置内部重绘贝塞尔曲线的控制点变量 */ private void sethinttexttranslationx(float deltax) { float offsetx = 0; if (mmoretextview != null) { mhintleftmargin += deltax; if (mhintleftmargin <= moffsetwidth) { offsetx = moffsetwidth; mmoretextview.setverticaltext(release_more); } else { offsetx = mhintleftmargin; mmoretextview.setverticaltext(scroll_more); } mmoretextview.setshadowoffset(offsetx, moffsetwidth); mmoretextview.settranslationx(offsetx); } } public interface onshowmorelistener { void onrelease(); } private onshowmorelistener mlistener; public void setonshowmorelistener(onshowmorelistener listener) { this.mlistener = listener; }
如果是在一个列表中使用此控件,我们最好还需要处理请求父布局的拦截操作,比如:
case motionevent.action_move: float mdeltax = (ev.getrawx() - mlastx); float mdeltay = ev.getrawy() - mlasty; //拦截事件-让我滚 getparent().requestdisallowintercepttouchevent(true);
这行不行,行!但是这么已拦截当按在这个控件上往上下滑动的时候,同样不能生效,会导致断触的效果。所以我们需要让拦截事件只拦截水平方向的事件。
并且为了兼容处理,有些设备第一次触摸的时候,mdeltax 与 mdeltay 都为 0,从而无法拦截,所以我们需要做个判断,非第一次触摸才开始拦截。
switch (ev.getaction()) { case motionevent.action_down: mhintleftmargin = 0; mmoveindex = 0; isfirstmove = true; mlastx = ev.getrawx(); mlasty = ev.getrawy(); break; case motionevent.action_move: // 释放动画 if (mreleasedanim != null && mreleasedanim.isrunning()) { break; } float mdeltax = (ev.getrawx() - mlastx); float mdeltay = ev.getrawy() - mlasty; if (isfirstmove) { // 处理事件冲突 if (math.abs(mdeltax) > math.abs(mdeltay)) { //拦截事件-让我滚 getparent().requestdisallowintercepttouchevent(true); } else { //拦截事件-父布局滚 getparent().requestdisallowintercepttouchevent(false); } } mmoveindex++; if (mmoveindex > 2) { isfirstmove = false; } mlastx = ev.getrawx(); mlasty = ev.getrawy();
使用与监听:
val group5 = findviewbyid<viewgroup5>(r.id.viewgroup5) val recyclerview = findviewbyid<recyclerview>(r.id.recycler_view) recyclerview.horizontal().binddata(list, r.layout.item_scroll_card) { holder: viewholder, t: string, position: int -> holder.settext(r.id.tv_name, "测试数据 $t") } group5.setonshowmorelistener { toast("进入更多的页面") }
效果:
如果嵌套会怎样?
如果和豆瓣的滑动效果与闲鱼的滑动进入详情效果放在一起:
当我们滑动正常布局可以触发闲鱼的滑动,当我们滑动豆瓣的滑动详情,则是请求父布局不要拦截,可以正常的触发滑动的效果,确实也符合我们的预期。
后记
其实本文并没有什么新的知识点,无非就是在 viewgroup 的测量布局的基础上,加上事件的处理,从易到难实现各种右滑进入详情的效果。
只是需要注意的是,事件的处理与滑动有多种组合的方式实现,我们还是需要按需选择,比如有一些处理滑动冲突的情况,最好我们还是使用 motionevent + scroller / settranslation 实现,对于一些不复杂的页面我们可以使用谷歌封装好的 viewdraghelper 来快速实现。
当然类似的效果也并不是只有自定义viewgroup可以实现,其他的类似 behavor 也能实现同样的效果,但我认为它并不属于自定义view体系,是另一个概念了,所以并没有对它有过多的介绍。如果真要扩展开来要讲的东西也太多了。
以上就是android自定义viewgroup实现右滑进入详情的详细内容,更多关于android viewgroup的资料请关注七九推其它相关文章!
发表评论