- 原文地址:Workcation App – Part 2. Animating Markers with MapOverlayLayout
- 原文作者:Mariusz Brona
- 譯文出自:掘金翻譯計劃
- 譯者:龍騎將楊影楓
- 校對者:Vivienmm、張拭心
Workcation App – 第二部分 . 帶有動畫的標記(Animating Markers) 與 MapOverlayLayout
歡迎閱讀本系列文章的第二篇,此係列文章和我前一段時間完成的“研究發”專案有關。在文章裡,我會針對開發中遇到的動畫問題分享一些解決辦法。
Part 1: 自定義 Fragment 轉場
Part 2: 帶有動畫的標記(Animating Markers) 與 MapOverlayLayout
Part 3: 帶有動畫的標記(Animated Markers) 與 RecyclerView 的互動
Part 4: 場景(Scenes)和 RecyclerView 的共享元素轉場動畫(Shared Element Transition)
專案的 Git 地址: Workcation App
動畫的 Dribbble 地址: dribbble.com/shots/28812…
序言
幾個月前我們開了一個部門會議,在會議上我的朋友 Paweł Szymankiewicz 給我演示了他在自己的“研發”專案上製作的動畫。我非常喜歡這個動畫,會後決定用程式碼實現它。我可沒想到到我會攤上啥…
GIF 1 “動畫效果”
開始吧!
就像上面 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)會更合理一些.我們有解決這個問題的方法嗎?當然,我們有!
MapOverlayLayout
該怎麼辦呢?其實很簡單,但我還是花了點時間才弄明白。我們需要在 SupportMapFragment 上(注:也就是上一篇提到的 MapFragment)新增一層使用谷歌地圖 API 所獲得的 MapOverlayLayout,在該層上新增地圖的對映(對映是用來轉換螢幕上的的座標和地理位置的實際座標,參見此文件)。
注:此處作者 via以後就沒東西了,我估計是手滑寫錯了。下面有個一模一樣的句子,但是多了一個說明,故此處按照下文翻譯。
類 MapOverlayLayout 是一個自定義的 幀佈局(FrameLayout),該佈局和 MapFragment 大小位置完全相同。當地圖載入完畢的時候,我們可以將 MapOverlayLayout 作為引數傳遞給 MapFragment,通過它用動畫載入自定義的 View 、根據手勢移動地圖鏡頭之類的事情。當然了,我們可以做現在需要的事情 —— 通過縮放和漸顯動畫新增標記 (也就是現在的自定義 View)、隱藏標記、當滑動 RecycleView 讓標記開始閃爍。
MapOverlayLayout – 新增
怎麼樣用 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 – 它是怎麼工作的呢?
那麼它究竟是如何工作的呢?
通過地圖對映(對映是用來轉換螢幕上的的座標和地理位置的實際座標,參見此文件)。我們可以拿到標記的橫座標與縱座標,通過座標來在 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、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。