- 原文地址:Workcation App – Part 2. Animating Markers with MapOverlayLayout
- 原文做者:Mariusz Brona
- 译文出自:掘金翻译计划
- 译者:龙骑将杨影枫
- 校对者:Vivienmm、张拭心
欢迎阅读本系列文章的第二篇,此系列文章和我前一段时间完成的“研究发”项目有关。在文章里,我会针对开发中遇到的动画问题分享一些解决办法。javascript
Part 1: 自定义 Fragment 转场前端
Part 2: 带有动画的标记(Animating Markers) 与 MapOverlayLayout java
Part 3: 带有动画的标记(Animated Markers) 与 RecyclerView 的互动react
Part 4: 场景(Scenes)和 RecyclerView 的共享元素转场动画(Shared Element Transition)android
项目的 Git 地址: Workcation Appios
动画的 Dribbble 地址: dribbble.com/shots/28812…git
几个月前咱们开了一个部门会议,在会议上个人朋友 Paweł Szymankiewicz 给我演示了他在本身的“研发”项目上制做的动画。我很是喜欢这个动画,会后决定用代码实现它。我可没想到到我会摊上啥...github
GIF 1 “动画效果”canvas
就像上面 GIF 动画展现的,须要作的事情有不少。后端
在点击底部菜单栏最右方的菜单后,咱们会跳转到一个新界面。在此界面中,地图经过缩放和渐显的转场动画在屏幕上方加载,Recycleview 的 item 随着转场动画从底部加载,地图上的标记点在转场动画执行的同时被添加到地图上.
当滑动底部的 RecycleView item 的时候,地图上的标记会经过闪烁来显示它们的位置(译者注:原文是show their position on the map,我的认为 position 有两层含义:一表明标记在地图上的位置,二表明标记所对应的 item 在 RecycleView 里序列的位置。)
在点击一个 item 之后,咱们会进入到新界面。在此界面中,地图经过动画方式来显示出路径以及起始/结束标记。同时此 RecyclerView 的item 会经过转场动画展现一些关于此地点的描述,背景图片也会放大,还附有更详细的信息和一个按钮。
当后退时,详情页经过转场变成普通的 RecycleView Item,全部的地图标记再次显示,同时路径一块儿消失。
就这么多啦,这就是我准备在这一系列文章中向你展现的东西。在本文中我会编写地图加载以及神秘的 MapWrapperLayout。敬请期待!
因此下一步的需求是:加载地图时展现全部由 API (一个解析 assets 文件夹中 JSON 文件的简单单例)提供的标记。幸运的是,前一章节里咱们已经描述过这些标记了。再下一步的需求是:使用渐显和缩放动画来加载这些标记。听起来很简单,但理想和现实老是有差距的。
不幸的是,谷歌地图 API 只容许咱们传递 BitmapDescriptor 类型的标记图标作参数,就像下面那样:
Java
GoogleMap map=...// 得到地图
// 经过蓝色的标记标注旧金山的位置
Marker marker=map.add(new MarkerOptions()
.position(new LatLng(37.7750,122.4183))
.title("San Francisco")
.snippet("Population: 776733"))
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE));复制代码
如效果所示,咱们须要在加载时实现标记渐显和缩放动画,滑动 RecycleView 的时候实现标记闪烁动画,进入详情页面的时候让标记在渐隐动画中隐藏。使用帧动画或者属性动画(Animation/ViewPropertyAnimator API)会更合理一些.咱们有解决这个问题的方法吗?固然,咱们有!
该怎么办呢?其实很简单,但我仍是花了点时间才弄明白。咱们须要在 SupportMapFragment 上(注:也就是上一篇提到的 MapFragment)添加一层使用谷歌地图 API 所得到的 MapOverlayLayout,在该层上添加地图的映射(映射是用来转换屏幕上的的坐标和地理位置的实际坐标,参见此文档)。
注:此处做者 via之后就没东西了,我估计是手滑写错了。下面有个如出一辙的句子,可是多了一个说明,故此处按照下文翻译。
类 MapOverlayLayout 是一个自定义的 帧布局(FrameLayout),该布局和 MapFragment 大小位置彻底相同。当地图加载完毕的时候,咱们能够将 MapOverlayLayout 做为参数传递给 MapFragment,经过它用动画加载自定义的 View 、根据手势移动地图镜头之类的事情。固然了,咱们能够作如今须要的事情 —— 经过缩放和渐显动画添加标记 (也就是如今的自定义 View)、隐藏标记、当滑动 RecycleView 让标记开始闪烁。
怎么样用 SupportMapFragment 和 谷歌地图添加一个 MapOverlayLayout 呢?
第一步,让咱们先看看 DetailsFragment 的 XML 文件:
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<fragment
android:id="@+id/mapFragment"
class="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="@dimen/map_margin_bottom"/>
<com.droidsonroids.workcation.common.maps.PulseOverlayLayout
android:id="@+id/mapOverlayLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="@dimen/map_margin_bottom">
<ImageView
android:id="@+id/mapPlaceholder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:transitionName="@string/mapPlaceholderTransition"/>
</com.droidsonroids.workcation.common.maps.PulseOverlayLayout>
...
</android.support.design.widget.CoordinatorLayout>复制代码
如咱们所见,有一个和 SupportMapFragment 尺寸相同、位置(marginBottom)也同样的 PulseOverlayLayout 盖在(SupportMapFragment )上面。PulseOverlayLayout 继承自 MapOverlayLayout,根据 app 须要添加了本身独有的逻辑(好比说 点击 RecycleView 时在界面上添加开始标记与结束标记,建立 PulseMarkerView _ 一个在以后会解释的自定义 View)。在布局中还包含一个 ImageView,这是我以前准备建立的转场动画的占位符。 xml 的工做就完成了,如今就开始专一于代码实现 —— DetailsFragment。
如今就开始专一于代码实现 DetailsFragment。
public class DetailsFragment extends MvpFragment<DetailsFragmentView,DetailsFragmentPresenter> implements DetailsFragmentView, OnMapReadyCallback{
public static final String TAG = DetailsFragment.class.getSimpleName();
@BindView(R.id.recyclerview)
RecyclerView recyclerView;
@BindView(R.id.container)
FrameLayout containerLayout;
@BindView(R.id.mapPlaceholder)
ImageView mapPlaceholder;
@BindView(R.id.mapOverlayLayout)
PulseOverlayLayout mapOverlayLayout;
@Override
public void onViewCreated(final View view,@Nullable final Bundle savedInstanceState){
super.onViewCreated(view,savedInstanceState);
setupBaliData();
setupMapFragment();
}
private void setupBaliData(){
presenter.provideBaliData();
}
private void setupMapFragment(){
((SupportMapFragment)getChildFragmentManager().findFragmentById(R.id.mapFragment)).getMapAsync(this);
}
@Override
public void onMapReady(final GoogleMap googleMap){
mapOverlayLayout.setupMap(googleMap);
setupGoogleMap();
}
private void setupGoogleMap(){
presenter.moveMapAndAddMarker();
}
@Override
public void provideBaliData(final List<Place>places){
baliPlaces=places;
}
@Override
public void moveMapAndAddMaker(final LatLngBounds latLngBounds){
mapOverlayLayout.moveCamera(latLngBounds);
mapOverlayLayout.setOnCameraIdleListener(()->{
for(int i=0;i<baliPlaces.size();i++){
mapOverlayLayout.createAndShowMarker(i,baliPlaces.get(i).getLatLng());
}
mapOverlayLayout.setOnCameraIdleListener(null);
});
mapOverlayLayout.setOnCameraMoveListener(mapOverlayLayout::refresh);
}
}复制代码
如上所示,地图经过 onMapReady 和上一篇同样进行加载。在接收回调后。咱们就能够更新地图的边界,在 MapOverlayLayout 添加标记,设置监听。
在下面的代码中,咱们会把地图镜头移动到能够展现咱们全部标记的地方。而后当镜头移动完毕时,在地图上创造并展现标记。在这以后,咱们设置 OnCameraIdleListener 空(null)。由于咱们但愿再次移动镜头时不要添加标记。在最后一行代码中,咱们为 OnCameraMoveListener 设置了刷新全部标记位置的动做。
@Override
public void moveMapAndAddMaker(final LatLngBounds latLngBounds){
mapOverlayLayout.moveCamera(latLngBounds);
mapOverlayLayout.setOnCameraIdleListener(()->{
for(int i=0;i<baliPlaces.size();i++){
mapOverlayLayout.createAndShowMarker(i,baliPlaces.get(i).getLatLng());
}
mapOverlayLayout.setOnCameraIdleListener(null);
});
mapOverlayLayout.setOnCameraMoveListener(mapOverlayLayout::refresh);
}复制代码
那么它到底是如何工做的呢?
经过地图映射(映射是用来转换屏幕上的的坐标和地理位置的实际坐标,参见此文档)。咱们能够拿到标记的横坐标与纵坐标,经过坐标来在 MapOverlayLayout 上放置标记的自定义 View。
这种作法可让咱们使用好比自定义 View 的属性动画(ViewPropertyAnimator )API 建立动画效果。
public class MapOverlayLayout<V extends MarkerView> extends FrameLayout{
protected List<V> markersList;
protected Polyline currentPolyline;
protected GoogleMap googleMap;
protected ArrayList<LatLng>polylines;
public MapOverlayLayout(final Context context){
this(context,null);
}
public MapOverlayLayout(final Context context,final AttributeSet attrs){
super(context,attrs);
markersList=newArrayList<>();
}
protected void addMarker(final V view){
markersList.add(view);
addView(view);
}
protected void removeMarker(final V view){
markersList.remove(view);
removeView(view);
}
public void showMarker(final int position){
markersList.get(position).show();
}
private void refresh(final int position,final Point point){
markersList.get(position).refresh(point);
}
public void setupMap(final GoogleMap googleMap){
this.googleMap = googleMap;
}
public void refresh(){
Projection projection=googleMap.getProjection();
for(int i=0;i<markersList.size();i++){
refresh(i,projection.toScreenLocation(markersList.get(i).latLng()));
}
}
public void setOnCameraIdleListener(final GoogleMap.OnCameraIdleListener listener){
googleMap.setOnCameraIdleListener(listener);
}
public void setOnCameraMoveListener(final GoogleMap.OnCameraMoveListener listener){
googleMap.setOnCameraMoveListener(listener);
}
public void moveCamera(final LatLngBounds latLngBounds){
googleMap.moveCamera(CameraUpdateFactory.newLatLngBounds(latLngBounds,150));
}
}复制代码
解释一下在 moveMapAndAddMarker 里调用的方法:为 CameraListeners 监听提供了 set 方法;刷新方法是为了更新标记的位置;addMarker 和 removeMarker 是用来添加 MarkerView (也就是上文所说的自定义 view )到布局和列表中。经过这个方案,MapOverlayLayout持有了全部被添加到自身的 View 引用。在类的最上面的是继承自 自定义 View —— MarkerView —— 的泛型。MarkerView 是一个继承自 View 的抽象类,看起来像这样:
public abstract class MarkerView extends View{
protected Point point;
protected LatLng latLng;
private MarkerView(final Context context){
super(context);
}
public MarkerView (final Context context,final LatLng latLng,final Point point){
this(context);
this.latLng=latLng;
this.point=point;
}
public double lat(){
return latLng.latitude;
}
public double lng(){
return latLng.longitude;
}
public Point point(){
return point;
}
public LatLng latLng(){
return latLng;
}
public abstract voi dshow();
public abstract void hide();
public abstract void refresh(final Point point);
}复制代码
经过抽象方法 show, hide 和 refresh ,咱们可以指定该标记显示、消失和刷新的方式。它还须要 Context 对象、经纬度和在屏幕上的坐标点。咱们一块儿来看看它的实现类:
public class PulseMarkerView extends MarkerView{
private static final int STROKE_DIMEN=2;
private Animation scaleAnimation;
private Paint strokeBackgroundPaint;
private Paint backgroundPaint;
private String text;
private Paint textPaint;
private AnimatorSet showAnimatorSet,hideAnimatorSet;
public PulseMarkerView(final Context context,final LatLng latLng,final Point point){
super(context,latLng,point);
this.context=context;
setVisibility(View.INVISIBLE);
setupSizes(context);
setupScaleAnimation(context);
setupBackgroundPaint(context);
setupStrokeBackgroundPaint(context);
setupTextPaint(context);
setupShowAnimatorSet();
setupHideAnimatorSet();
}
public PulseMarkerView(final Context context,final LatLng latLng,final Point point,final int position){
this(context,latLng,point);
text=String.valueOf(position);
}
private void setupHideAnimatorSet(){
Animator animatorScaleX=ObjectAnimator.ofFloat(this,View.SCALE_X,1.0f,0.f);
Animator animatorScaleY=ObjectAnimator.ofFloat(this,View.SCALE_Y,1.0f,0.f);
Animator animator=ObjectAnimator.ofFloat(this,View.ALPHA,1.f,0.f).setDuration(300);
animator.addListener(newAnimatorListenerAdapter(){
@Override
publicvoidonAnimationStart(finalAnimator animation){
super.onAnimationStart(animation);
setVisibility(View.INVISIBLE);
invalidate();
}
});
hideAnimatorSet=newAnimatorSet();
hideAnimatorSet.playTogether(animator,animatorScaleX,animatorScaleY);
}
private void setupSizes(finalContext context){
size=GuiUtils.dpToPx(context,32)/2;
}
private void setupShowAnimatorSet(){
Animator animatorScaleX=ObjectAnimator.ofFloat(this,View.SCALE_X,1.5f,1.f);
Animator animatorScaleY=ObjectAnimator.ofFloat(this,View.SCALE_Y,1.5f,1.f);
Animator animator=ObjectAnimator.ofFloat(this,View.ALPHA,0.f,1.f).setDuration(300);
animator.addListener(newAnimatorListenerAdapter(){
@Override
public void onAnimationStart(finalAnimator animation){
super.onAnimationStart(animation);
setVisibility(View.VISIBLE);
invalidate();
}
});
showAnimatorSet = newAnimatorSet();
showAnimatorSet.playTogether(animator,animatorScaleX,animatorScaleY);
}
private void setupScaleAnimation(final Context context){
scaleAnimation=AnimationUtils.loadAnimation(context,R.anim.pulse);
scaleAnimation.setDuration(100);
}
private void setupTextPaint(final Context context){
textPaint=newPaint();
textPaint.setColor(ContextCompat.getColor(context,R.color.white));
textPaint.setTextAlign(Paint.Align.CENTER);
textPaint.setTextSize(context.getResources().getDimensionPixelSize(R.dimen.textsize_medium));
}
private void setupStrokeBackgroundPaint(final Context context){
strokeBackgroundPaint=newPaint();
strokeBackgroundPaint.setColor(ContextCompat.getColor(context,android.R.color.white));
strokeBackgroundPaint.setStyle(Paint.Style.STROKE);
strokeBackgroundPaint.setAntiAlias(true);
strokeBackgroundPaint.setStrokeWidth(GuiUtils.dpToPx(context,STROKE_DIMEN));
}
private void setupBackgroundPaint(final Context context){
backgroundPaint=newPaint();
backgroundPaint.setColor(ContextCompat.getColor(context,android.R.color.holo_red_dark));
backgroundPaint.setAntiAlias(true);
}
@Override
public void setLayoutParams(final ViewGroup.LayoutParams params){
FrameLayout.LayoutParams frameParams=newFrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,FrameLayout.LayoutParams.WRAP_CONTENT);
frameParams.width=(int)GuiUtils.dpToPx(context,44);
frameParams.height=(int)GuiUtils.dpToPx(context,44);
frameParams.leftMargin=point.x-frameParams.width/2;
frameParams.topMargin=point.y-frameParams.height/2;
super.setLayoutParams(frameParams);
}
public void pulse(){
startAnimation(scaleAnimation);
}
@Override
protected void onDraw(final Canvas canvas){
drawBackground(canvas);
drawStrokeBackground(canvas);
drawText(canvas);
super.onDraw(canvas);
}
private void drawText(final Canvas canvas){
if(text!=null&&!TextUtils.isEmpty(text))
canvas.drawText(text,size,(size-((textPaint.descent()+textPaint.ascent())/2)),textPaint);
}
private void drawStrokeBackground(final Canvas canvas){
canvas.drawCircle(size,size,GuiUtils.dpToPx(context,28)/2,strokeBackgroundPaint);
}
private void drawBackground(final Canvas canvas){
canvas.drawCircle(size,size,size,backgroundPaint);
}
public void setText(Stringtext){
this.text=text;
invalidate();
}
@Override
public void hide(){
hideAnimatorSet.start();
}
@Override
public void refresh(finalPoint point){
this.point=point;
updatePulseViewLayoutParams(point);
}
@Override
public void show(){
showAnimatorSet.start();
}
public void showWithDelay(final int delay){
showAnimatorSet.setStartDelay(delay);
showAnimatorSet.start();
}
public void updatePulseViewLayoutParams(final Point point){
this.point=point;
FrameLayout.LayoutParams params=newFrameLayout.LayoutParams(FrameLayout.LayoutParams.WRAP_CONTENT,FrameLayout.LayoutParams.WRAP_CONTENT);
params.width=(int)GuiUtils.dpToPx(context,44);
params.height=(int)GuiUtils.dpToPx(context,44);
params.leftMargin=point.x-params.width/2;
params.topMargin=point.y-params.height/2;
super.setLayoutParams(params);
invalidate();
}
}复制代码
这是继承自 MarkerView 的 PulseMarkerView。在构造方法(constructor)中,咱们设置一个显示、消失和闪烁的动画序列(AnimatorSets)。在重写 MarkerView 的方法里,咱们只是单纯的启动了这个动画序列。updatePulseViewLayoutParams 中更新了屏幕上的 PulseViewMarker。接下来就是使用构造方法里建立的 Paints 来绘制界面。
效果:
加载地图和滑动 RecycleView
移动地图镜头时刷新标记
地图缩放
缩放和滚动效果
如上所示,这种作法有一个巨大的优点 —— 咱们能够普遍的使用自定义 View 的力量。不过呢,移动地图和刷新标记位置的时候会有一点小延迟。和完成的需求相比,这是能够能够接受的代价。
多谢阅读!下一篇会在周二 14:03 更新。若是有任何疑问,欢迎评论。若是以为有帮助的话,不要忘记分享哟。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、React、前端、后端、产品、设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划。