[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

龍騎將楊影楓發表於2019-02-27

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 給我演示了他在自己的“研發”專案上製作的動畫。我非常喜歡這個動畫,會後決定用程式碼實現它。我可沒想到到我會攤上啥...

[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

GIF 1 “動畫效果”

開始吧!

就像上面 GIF 動畫展示的,需要做的事情有很多。

  1. 在點選底部選單欄最右方的選單後,我們會跳轉到一個新介面。在此介面中,地圖通過縮放和漸顯的轉場動畫在螢幕上方載入,Recycleview 的 item 隨著轉場動畫從底部載入,地圖上的標記點在轉場動畫執行的同時被新增到地圖上.

  2. 當滑動底部的 RecycleView item 的時候,地圖上的標記會通過閃爍來顯示它們的位置(譯者注:原文是show their position on the map,個人認為 position 有兩層含義:一代表標記在地圖上的位置,二代表標記所對應的 item 在 RecycleView 裡序列的位置。)

  3. 在點選一個 item 以後,我們會進入到新介面。在此介面中,地圖通過動畫方式來顯示出路徑以及起始/結束標記。同時此 RecyclerView 的item 會通過轉場動畫展示一些關於此地點的描述,背景圖片也會放大,還附有更詳細的資訊和一個按鈕。

  4. 當後退時,詳情頁通過轉場變成普通的 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 方法;重新整理方法是為了更新標記的位置;addMarkerremoveMarker 是用來新增 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, hiderefresh ,我們能夠指定該標記顯示、消失和重新整理的方式。它還需要 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 來繪製介面。

效果:

[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

載入地圖和滑動 RecycleView

[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

移動地圖鏡頭時重新整理標記

[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

地圖縮放

[譯]Workcation App – 第二部分 .帶有動畫的標記(Animating Markers) 與 MapOverlayLayout

縮放和滾動效果

總結

如上所示,這種做法有一個巨大的優勢 —— 我們可以廣泛的使用自定義 View 的力量。不過呢,移動地圖和重新整理標記位置的時候會有一點小延遲。和完成的需求相比,這是可以可以接受的代價。

多謝閱讀!下一篇會在週二 14:03 更新。如果有任何疑問,歡迎評論。如果覺得有幫助的話,不要忘記分享喲。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章