微信号:appjiagou

介绍:分享最有价值的APP技术干货文章,做一个有逼格的APP架构师,拒绝平庸,打造最有价值的APP社区!

Android 高仿懂球帝我是教练效果

2017-09-15 00:00 APP架构师

作者:IAM四十二
链接:http://www.jianshu.com/p/d06c1d10bf7f
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

天下无敌

前言

这几天很多欧洲球队来中国进行热身赛,不知道喜欢足球的各位小伙伴们有没有看球。喜欢足球的朋友可能知道懂球帝APP,鄙人也经常使用这个应用,里面有一个我是教练的功能挺好玩,就是可以模拟教练员的身份,排兵布阵;本着好奇心简单模仿了一下,在这里和大家分享。

效果图

老规矩,先上效果图看看模仿的像不。

add_player.gif

move_player

玩过我是教练这个功能的小伙伴可以对比一下。

总的来说,这样的一个效果,其实很简单,就是一个view随着手指在屏幕上移动的效果,外加一个图片替换的动画。但就是这些看似简单的效果,在实现的过程中也是遇到了很多坑,涨了许多新姿势。好了,废话不说,代码走起(。◕ˇ∀ˇ◕)。

自定义View-BallGameView

整个内容中最核心的就是一个自定义View-BallGameView,就是屏幕中绿色背景,有气泡和球员图片的整个view。

说到自定义View,老生常谈,大家一直都在学习,却永远都觉得自己没有学会,但是自定义View的知识本来就很多呀,想要熟练掌握,必须假以时日

既然是自定View就从大家最关心的两个方法 onMeasure和onDraw 两个方法说起。这里由于是纯粹继承自View,就不考虑onLayout的实现了。

测量-onMeasure

这里onMeasure()方法的实现很简单,简单的用屏幕的宽度规定了整个View 的宽高;至于1.3这个倍数,完全一个估算值,不必深究。

绘制-onDraw

onDraw()方法是整个View中最核心的方法。

可以看到,在onDraw方法里,我们主要使用了canvas.drawBitmap 方法,绘制了很多图片。下面就简单了解一下canvas.drawBitmap 里的两个重载方法。

  • drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint)

 

drawBitmap(Bitmap bitmap,Rect src,Rect dst,Paint paint),这个重载方法主要是通过两个Rectangle 决定了bitmap以怎样的形式绘制出来。简单来说,src 这个长方形决定了“截取”bitmap的大小,dst 决定了最终绘制出来时Bitmap应该占有的大小。。就拿上面的代码来说

       

bitmapRect 是整个backgroundBitmap的大小,mViewRect也就是我们在onMeasure里规定的整个视图的大小,这样相当于把battle_bg这张图片,以scaleType="fitXY"的形式画在了视图大小的区域内。这样,你应该理解这个重载方法的含义了。

  • drawBitmap(Bitmap bitmap, float left, float top, Paint paint)

 

这个重载方法应该很容易理解了,left,top 规定了绘制Bitmap的左上角的坐标,然后按照其大小正常绘制即可。

这里我们所有的气泡(球员位置)都是使用这个方法绘制的。足球场上有11个球员,因此我们通过数组预先定义了11个气泡的初始位置,然后通过其坐标位置,绘制他们。为了绘制精确,需要减去每张图片自身的宽高,这应该是很传统的做法了。

同时,在之后的触摸反馈机制中,我们会根据手指的滑动,修改这些坐标值,这样就可以随意移动球员在场上的位置了;具体实现,结合代码中的注释应该很容易理解了,就不再赘述;可以查看完整源码BallGameView。

文字居中绘制

这里再说一个在绘制过程中遇到一个小问题,可以看到在整个视图底部,绘制了一个半透明的圆角矩形,并在他上面绘制了一行黄色的文字,这行文字在水平和垂直方向都是居中的;使用TextPaint 绘制文字实现水平居中是很容易的事情,只需要设置mTipPaint.setTextAlign(Paint.Align.CENTER)即可,但是在垂直方向实现居中,就没那么简单了,这里需要考虑一个文本绘制时基线的问题,具体细节可以参考这篇文章,分析的很详细。

我们在这里为了使文字在圆角矩形中居中,如下实现。

     

圆角矩形的垂直中心点的基础上,再一次做修正,确保实现真正的垂直居中。

好了,结合扔物线大神所总结的自定义View关键步骤,以上两点算是完成了绘制和布局的工作,下面就看看触摸反馈的实现。

触摸反馈-onTouchEvent

这里触摸反馈机制,使用到了GestureDetector这个类;这个类可以用来进行手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。内部提供了OnGestureListener、OnDoubleTapListener和OnContextClickListener三个接口,并提供了一系列的方法,比如常见的

  • onSingleTapUp : 手指轻触屏幕离开

  • onScroll : 滑动

  • onLongPress: 长按

  • onFling: 按下后,快速滑动松开(类似切水果的手势)

  • onDoubleTap : 双击

可以看到,使用这个类可以更加精确的处理手势操作。

这里引入GestureDetector的原因是这样的,单独在onTouchEvent处理所有事件时,在手指点击屏幕的瞬间,很容易触发MotionEvent.ACTION_MOVE事件,导致每次触碰气泡,被点击气泡的位置都会稍微颤抖一下,位置发生轻微的偏移,体验十分糟糕。采用GestureDetector对手指滑动的处理,对点击和滑动的检测显得更加精确

 

这里m_gestureDetector.onTouchEvent(event),这样就可以让GestureDetector在他自己的回调方法OnGestureListener里,处理触摸事件。

上面的逻辑很简单,动画正在进行是,直接返回。MotionEvent.ACTION_DOWN事件发生时的处理逻辑,通过注释很容易理解,就不再赘述。

当我们点击到某个气泡时,就获取到了当前选中位置currentPos;下面看看GestureDetector的回调方法,是怎样处理滑动事件的。

SimpleOnGestureListener 默认实现了OnGestureListener,OnDoubleTapListener, OnContextClickListener这三个接口中所有的方法,因此非常方便我们使用GestureDetector进行特定手势的处理。

这里的处理很简单,当气泡被选中时moveEnable=true,通过onScroll回调方法返回的距离,不断更新当前位置的坐标,同时记得限制一下手势滑动的边界,总不能把球员移动到场地外面吧o(╯□╰)o,最后的postInvalidate()是关键,触发onDraw方法,实现重新绘制。

这里有一个细节,不知你发现没有,我们在更新坐标的时候,每次都是在当前坐标的位置,减去了滑动距离(distanceX/distanceY)。这是为什么(⊙o⊙)?,为什么不是加呢?

我们可以看看这个回调方法的定义

   

可以看到,这里特定强调了This is NOT the distance between {@code e1}and {@code e2},就是说这个距离并不是两次事件e1和e2 之间的距离。那么这个距离又是什么呢?那我们就找一找到底是在哪里触发了这个回调方法.

最终在GestureDetector类的onTouchEvent()方法里找到了触发这个方法发生的地方:

这里还涉及到多指触控的考虑,情况较为复杂;简单说一下结论,在ACTION_MOVE时,会从上一次手指离开的距离,减去此次手指触碰的位置;这样当scrollX>0时,就是在向右滑动,反之向左;scrollY > 0 时,是在向上滑动,反之向下;因此,这两个距离和我们习以为常的方向恰好都是相反的,因此,在更新坐标时,需要做相反的处理。

有兴趣的同学,可以把上面的“-”改成“+”,尝试运行一下代码,就会明白其中的道理了。

好了,到了这里按照绘制,布局,触摸反馈的顺序我们已经完成了BallGameView这个自定义View自己的内容了,但是我们还看到在点击下面的球员头像时,还有一个简单的动画,下面就看看动画是如何实现的。

动画效果

首先说明一下,底部球员列表是一个横向的RecyclerView,这样一个横向滑动的双列展示的RecyclerView 应该很简单了,这里就不再详述。文末有源码,最后可以查看。

这里看一下每一个RecyclerView中item的点击事件

这里可以看到调用了GameView的updatePlayer方法:

这个动画,简单来说就是一个一阶贝塞尔曲线。根据RecyclerView中item在屏幕中的位置,构造一个一模一样的ImageView添加到根视图中,然后通过一个属性动画,在属性值不断更新时,在回调方法中不断调用setTranslation方法,改变这个ImageView的位置,呈现出动画的效果。动画结束后,将这个ImageView从视图移除,同时气泡中的数据即可,最后再次invalidate导致整个视图重新绘制,这样动画完成时,气泡就被替换为真实的头像了。

到这里,基本上所有功能,都实现了。最后就是把自己排出来的阵型,保存为图片分享给小伙伴了。这里主要说一下保存图片的实现;分享功能,就不作为重点讨论了。

自定义View保存为Bitmap

一个典型的AsyncTask实现,文件流的输出,没什么多说的。主要是存储目录的选择,这里有个技巧,如果没有特殊限制,平时我们做开发的时候,可以 把一些存储路径做如下定义

  • mContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES):代表/storage/emulated/0/Android/data/{packagname}/files/Pictures

  • mContext.getExternalCacheDir() 代表 /storage/emulated/0/Android/data/{packagname}/cache

对于mContext.getExternalFilesDir还可定义为Environment.DIRECTORY_DOWNLOADS,Environment.DIRECTORY_DOCUMENTS等目录,对应的文件夹名称也会变化。

这个目录中的内容会随着用户卸载应用,一并删除。最重要的是,读写这个目录是不需要权限的,因此省去了每次做权限判断的麻烦,而且也避免了没有权限时的窘境

到这里,模仿功能,全部都实现了。下面稍微来一点额外的扩展。

我们希望图片保存后可以在通知栏提示用户,点击通知栏后可以通过手机相册查看保存的图片。

扩展-Android  Notification & FileProvider 的使用

Android 系统中的通知栏,随着版本的升级,已经形成了固定了写法,在Builder模式的基础上,通过链式写法,可以非常方便的设置各种属性。这里重点说一下PendingIntent的用法,我们知道这个PendingIntent 顾名思义,就是处于Pending状态,当我们点击通知栏,就会触发他所包含的Intent。

严格来说,通过自己的应用想用手机自带相册打开一张图片是无法实现的,因为无法保证每一种手机上面相册的包名是一样的,因此这里我们创建ACTION=Intent.ACTION_VIEW的 Intent,去匹配系统所有符合这个Action 的Activity,系统相册一定是其中之一。

到这里,还有一定需要注意,Android 7.0 开始,无法以file://xxxx 形式向外部应用提供内容了,因此需要考虑使用FileProvider。当然,对这个问题,Google官方提供了完整的使用实例,实现起来都是套路,没有什么特别之处。

重点记住下面的对应关系即可:

按照上面,我们存储图片的目录,我们在file_path.xml 做如下定义即可:

在AndroidManifest中完成如下配置 :

   

这样,当Build.VERSION.SDK_INT大于等于24及Android7.0时,可以安心的使用FileProvider来和外部应用共享文件了。

最后

好了,从一个简单的自定义View 出发,又牵出了一大堆周边的内容。好在,总算完整的说完了。

特别申明

以上代码中所用到的图片资源,全部源自懂球帝APP内;此处对应用解包,只是本着学习的目的,没有其他任何用意。


源码地址: Github-AndroidAnimationExercise。

有兴趣的同学欢迎 star &  fork。


如果你有好的文章想和大家分享欢迎投稿,直接向我投递文章链接即可。



 
APP架构师 更多文章 【实战总结】帧动画调优实践 【经验总结】如何使用Android自带的资源 【实战】快速实现炫酷的ViewPager卡片效果 自定义SwipeRefreshLayout样式—高仿微信朋友圈下拉刷新 【附源码地址】Android高仿QQ及微信底部菜单的多种实现方式
猜您喜欢 图数据库——大数据时代的高铁 数据库备份还原系统 【Android 原创】对某航软件URL参数的解密 WSC“创+”微生态思想汇 八月武汉、广州、上海三城火热报名中 第六届全国社会媒体处理大会征稿通知