[譯]Workcation App – 第一部分 . 自定義 Fragment 轉場動畫

龍騎將楊影楓發表於2019-03-01

Workcation App – 第一部分 . 自定義 Fragment 轉場動畫

歡迎閱讀本系列文章的第一篇,此係列文章和我前一段時間完成的“研發”專案有關。在文章裡,我會針對開發中遇到的動畫問題分享一些解決辦法。

Part 1: 自定義 Fragment 轉場

Part 2: Animating Markers 與 MapOverlayLayout

Part 3: RecyclerView 互動 與 Animated Markers

Part 4: 場景(Scenes)和 RecyclerView 的共享元素轉場動畫(Shared Element Transition)

專案的 Git 地址: Workcation App

動畫的 Dribbble 地址: dribbble.com/shots/28812…

序言

幾個月前我們開了一個部門會議,在會議上我的朋友 Paweł Szymankiewicz 給我演示了他在自己的“研發”專案上製作的動畫。我非常喜歡這個動畫,會後決定用程式碼實現它。我可沒想到到我會攤上啥…

[譯]Workcation App – 第一部分 . 自定義 Fragment 轉場動畫

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,所有的地圖示記再次顯示,同時路徑一起消失。

就這麼多啦,這就是我準備在這一系列文章中向你展示的東西。在本文中我會編寫進入地圖 fragment 的轉場動畫。

難點

就像我們在 GIF 1 裡看到的那樣,看起來好像地圖在移動到正確地點之前已經載入完畢了。這在真實世界裡是不可能的,它實際上是這個樣子的:

[譯]Workcation App – 第一部分 . 自定義 Fragment 轉場動畫

需求

  1. 預載入地圖

  2. 載入完畢後,使用 Google Map API 獲得地圖的快照圖片(bitmap)並儲存在快取中。

  3. 為地圖編寫一個包含縮放與漸顯的自定義轉場動畫(transition),進入 DetailsFragment 的時候就啟用。

動手吧!

預載入地圖

為了實現上述目標,我們首先從已載入的地圖上拿到一份快照(snapshot)。當然我們如果想把轉場動畫做的更平滑一點,肯定不能等進入 DetailsFragment 後才獲取。所以要怎麼做呢?當然是是悄悄的在 HomeFragment 裡拿到這個圖片(bitmap) 並且儲存在快取裡啦。地圖距離底部還有一點距離(margin),所以我們拿到的圖片必須滿足”將來的”地圖尺寸。

XHTML

<?xml version="1.0"encoding="utf-8"?>

<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"

    xmlns:tools="http://schemas.android.com/tools"

    android:layout_width="match_parent"

    android:layout_height="match_parent"

    xmlns:app="http://schemas.android.com/apk/res-auto"

    tools:MContext=".screens.main.MainActivity">



    <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"/>



    <LinearLayout

        android:layout_width="match_parent"

        android:layout_height="match_parent"

        android:orientation="vertical"

        android:background="@color/white">

        ...

        ...

        </LinearLayout>

    </android.support.design.widget.CoordinatorLayout>複製程式碼

就像上面程式碼展示的那樣,MapFragment 被放在佈局的最下方,這樣我們就可以在使用者看不到地方載入地圖。

public class MainActivity extends MvpActivity<MainView,MainPresenter> implements MainView,OnMapReadyCallback{

    SupportMapFragment mapFragment;

    privateLatLngBounds mapLatLngBounds;

    @Override

    protected void onCreate(Bundle savedInstanceState){

        super.onCreate(savedInstanceState);

        presenter.provideMapLatLngBounds();

        getSupportFragmentManager()

                .beginTransaction()

                .replace(R.id.container,HomeFragment.newInstance(),HomeFragment.TAG)

                .addToBackStack(HomeFragment.TAG)

                .commit();

        mapFragment=(SupportMapFragment)getSupportFragmentManager().findFragmentById(R.id.mapFragment);

        mapFragment.getMapAsync(this);

    }



    @Override

    public void setMapLatLngBounds(final LatLngBounds latLngBounds){

        mapLatLngBounds=latLngBounds;

    }



    @Override

    public void onMapReady(final GoogleMap googleMap){

        googleMap.moveCamera(CameraUpdateFactory.newLatLngBounds(

                mapLatLngBounds,

                MapsUtil.calculateWidth(getWindowManager()),

                MapsUtil.calculateHeight(getWindowManager(),getResources().getDimensionPixelSize(R.dimen.map_margin_bottom)),

                MapsUtil.DEFAULT_ZOOM));

        googleMap.setOnMapLoadedCallback(()->googleMap.snapshot(presenter::saveBitmap));

    }

}複製程式碼

MainActivity 繼承自 MvpActivity,而 MvpActivity 是來自 Hannes Dorfmann 寫的 Mosby Framework。我的專案都遵從 MVP 模式,而這個框架是一個 MVP 模式的非常好的實現。

在 onCreate 方法裡我們做了三件事:

  1. 為地圖提供了 LatLngBounds,他們會被用來設定地圖的邊界。

  2. 在 activity 的佈局里載入了 HomeFragment

  3. Mapfragment 設定了 OnMapReadyCallback 的回撥。

當地圖載入完畢時,就會呼叫 onMapReady() 方法,我們就可以通過一些操作把當前載入的地圖轉換成 bitmap 圖片。通過 CameraUpdateFactory.newLatLngBounds() 方法,我們可以把鏡頭轉到之前提供的 LatLngBounds 上。這樣的話我們就精確的知道下個頁面的地圖區域,再把螢幕寬度和高度當作引數傳入 onMapReady() 方法,像這樣操作:

public static int calculateWidth(final WindowManager windowManager){

    DisplayMetrics metrics=newDisplayMetrics();

    windowManager.getDefaultDisplay().getMetrics(metrics);

    returnmetrics.widthPixels;

}


public static int calculateHeight(final WindowManager windowManager,finalintpaddingBottom){

    DisplayMetrics metrics=newDisplayMetrics();

    windowManager.getDefaultDisplay().getMetrics(metrics);

    returnmetrics.heightPixels-paddingBottom;複製程式碼

很簡單吧?在呼叫 googleMap.moveCamera() 方法以後,我們設定 OnMapLoadedCallback 的回撥。當鏡頭移動到正確的位置的時候,呼叫 onMapLoaded() 方法,我們準備好在此處截圖了。

獲得圖片並儲存在快取中

onMapLoaded() 方法只做一件事 —— 在從地圖上獲得快照後呼叫 presenter.saveBitmap() 方法。多虧 lambda 表示式,我們可以縮短程式碼到一行。(譯者注:有關 lamb 表示式,推薦搭配此文章一起食用。)

googleMap.setOnMapLoadedCallback(()->googleMap.snapshot(presenter::saveBitmap));複製程式碼

此 presenter (譯者注:MVP 裡的 P) 的程式碼非常簡單,它只是把圖片儲存在快取裡。

@Override

public void saveBitmap(final Bitmap bitmap){

    MapBitmapCache.instance().putBitmap(bitmap);

}


public class MapBitmapCache extends LruCache<String,Bitmap>{

    private static final int DEFAULT_CACHE_SIZE=(int)(Runtime.getRuntime().maxMemory()/1024)/8;

    public static final String KEY="MAP_BITMAP_KEY";



    private static MapBitmapCache sInstance;

    /**

     * @param maxSize for caches that do not override {@link #sizeOf}, this is

     * the maximum number of entries in the cache. For all other caches,

     * this is the maximum sum of the sizes of the entries in this cache.

     */

    private MapBitmapCache(final int maxSize){

        super(maxSize);

    }



    public staticMapBitmapCache instance(){

        if(sInstance==null){

            sInstance=newMapBitmapCache(DEFAULT_CACHE_SIZE);

            returnsInstance;

        }

        returnsInstance;

    }



    public Bitmap getBitmap(){

        return get(KEY);

    }



    public void putBitmap(Bitmap bitmap){

        put(KEY,bitmap);

    }



    @Override

    protected intsizeOf(String key,Bitmap value){

        return value==null ? 0 : value.getRowBytes()*value.getHeight()/1024;

    }

}複製程式碼

此處我使用了 LruCache ,因為這是比較推薦的做法,這裡有詳細解釋。

現在我們把bitmap 存到了快取裡,剩下唯一要做的事情就是自定義一個縮放和漸進效果的轉場動畫。
毛毛雨灑灑水啦~(譯者注: 原文為 Easy peasy lemon squeezy。是一個比較有意思的、以俏皮的語氣表達“輕而易舉”或者“手到擒來”概念的短語。)

自定義一個包含縮放和漸顯效果的轉場

下面是最有意思的部分,程式碼也炒雞簡單!但就是這部分完成了比較炫酷的事情。

public class ScaleDownImageTransition extends Transition{

    private static final int DEFAULT_SCALE_DOWN_FACTOR = 8;

    private static final String PROPNAME_SCALE_X="transitions:scale_down:scale_x";

    private static final String PROPNAME_SCALE_Y="transitions:scale_down:scale_y";

    private Bitmap bitmap;

    private Context context;



    private int targetScaleFactor = DEFAULT_SCALE_DOWN_FACTOR;



    public ScaleDownImageTransition(final Context context){

        this.context=context;

        setInterpolator(newDecelerateInterpolator());

    }



    public ScaleDownImageTransition(final Context context,final Bitmap bitmap){

        this(context);

        this.bitmap=bitmap;

    }



    public ScaleDownImageTransition(final Context context,final AttributeSet attrs){

        super(context,attrs);

        this.context=context;

        TypedArray array=context.obtainStyledAttributes(attrs,R.styleable.ScaleDownImageTransition);

        try{

            targetScaleFactor=array.getInteger(R.styleable.ScaleDownImageTransition_factor,DEFAULT_SCALE_DOWN_FACTOR);

        }finally{

            array.recycle();

        }

    }



    public void setBitmap(final Bitmap bitmap){

        this.bitmap=bitmap;

    }



    public void setScaleFactor(final intfactor){

        targetScaleFactor=factor;

    }



    @Override

    public Animator createAnimator(final ViewGroup sceneRoot,final TransitionValues startValues,final TransitionValues endValues){

        if(null == endValues){

            return null;

        }

        final View view=endValues.view;

        if (view instanceof ImageView){

            if (bitmap!=null)
                view.setBackground(new BitmapDrawable(context.getResources(),bitmap));

            float scaleX=(float)startValues.values.get(PROPNAME_SCALE_X);

            float scaleY=(float)startValues.values.get(PROPNAME_SCALE_Y);



            float targetScaleX=(float)endValues.values.get(PROPNAME_SCALE_X);

            float targetScaleY=(float)endValues.values.get(PROPNAME_SCALE_Y);



            ObjectAnimator scaleXAnimator = ObjectAnimator.ofFloat(view,View.SCALE_X,targetScaleX,scaleX);

            ObjectAnimator scaleYAnimator = ObjectAnimator.ofFloat(view,View.SCALE_Y,targetScaleY,scaleY);

            AnimatorSet set=new AnimatorSet();

            set.playTogether(scaleXAnimator,scaleYAnimator,ObjectAnimator.ofFloat(view,View.ALPHA,0.f,1.f));

            return set;

        }

        return null;

    }



    @Override

    public void captureStartValues(TransitionValues transitionValues){

        captureValues(transitionValues,transitionValues.view.getScaleX(),transitionValues.view.getScaleY());

    }



    @Override

    public void captureEndValues(TransitionValues transitionValues){

        captureValues(transitionValues,targetScaleFactor,targetScaleFactor);

    }



    private void captureValues(final TransitionValues values,final float scaleX,final float scaleY){

        values.values.put(PROPNAME_SCALE_X,scaleX);

        values.values.put(PROPNAME_SCALE_Y,scaleY);

    }

}複製程式碼

在轉場動畫中做了什麼事情呢?我們用 scaleFactor 對傳入的 imageView 進行了 scaleX 和 scaleY 屬性的縮放(預設是8)。換句話說我們通過 scaleFactor 先把圖片拉伸,然後再把圖片壓縮回需要的大小。

建立自定義轉場動畫

為了編寫轉場動畫,我們必須繼承一個 Transition 類。然後重寫 captureStartValuescaptureEndValues 方法。猜猜發生了啥?

Transition 框架使用了屬性動畫的 API ,通過改變 view 開始和結束時的屬性值來產生動畫。如果你不熟悉屬性動畫,強烈推薦閱讀這篇文章。就像剛才解釋的那樣,我們要縮放圖片。開始值是 scaleFactor ,結束值是期望 scaleX 和 scaleY的值,通常情況下是1。

怎麼傳遞這些值呢?如前所述,很簡單。我們把 TransitionValues 物件當作引數傳進 captureStartcaptureEnd 方法裡。它包括一個 view 的引用和一個可以儲存值的 Map 物件,在我們的專案中需要儲存的值就是 scaleX 和 scaleY。

獲得這些值以後,我們需要重寫 createAnimator() 方法。在這個方法中需要返回一個動態改變 view 屬性的 Animator (或者 AnimatorSet )。本專案中返回的是 AnimatorSet 物件,此物件同時改變一個 view 的尺寸和亮度。同時,因為我們只希望轉場動畫作用在 ImageView 上,所以通過 instanceof 進行了物件型別校驗,以保證傳入的 view 是一個 ImageView。

部署自定義轉場動畫

我們已經在快取中儲存了 bitmap 圖片,也已經建立了轉場動畫,所以只剩最後一步 —— 就是為 fragment 新增轉場動畫。我喜歡寫一個靜態工廠方法來建立 fragments 和 activities 。這麼做可以讓我們保持程式碼邏輯清晰,所以也應該用這樣的設計模式來編寫轉場動畫的程式碼。

public static Fragment newInstance(final Context ctx){

    DetailsFragment fragment = new DetailsFragment();

    ScaleDownImageTransition transition=new ScaleDownImageTransition(ctx,MapBitmapCache.instance().getBitmap());

    transition.addTarget(ctx.getString(R.string.mapPlaceholderTransition));

    transition.setDuration(800);

    fragment.setEnterTransition(transition);

    return fragment;

}複製程式碼

瞧,做起來多簡單。我們為轉場動畫例項化了一個新的例項,又通過 xml 為它新增了 transitionName 的屬性。

<ImageView

    android:id="@+id/mapPlaceholder"

    android:layout_width="match_parent"

    android:layout_height="match_parent"

    android:layout_marginBottom="@dimen/map_margin_bottom"

    android:transitionName="@string/mapPlaceholderTransition"/>複製程式碼

然後我們通過 setEnterTransition() 把fragment 傳遞進去, 看吧!效果出現啦:

[譯]Workcation App – 第一部分 . 自定義 Fragment 轉場動畫

總結

你看,最終效果已經很接近像 GIF 那樣從本地載入地圖的效果了。但是最後一幀動畫仍然會有那麼一點閃爍,因為地圖的快照還是與實際的地圖有點差別。

多謝閱讀,下一部分會在星期二的 7.03 更新。如果有疑問的話,歡迎評論。當然如果發現這些博文很有趣,不要忘記分享噢。


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

相關文章