- 原文地址:Workcation App – Part 1. Fragment custom transition
- 原文作者:Mariusz Brona
- 譯文出自:掘金翻譯計劃
- 譯者:龍騎將楊影楓
- 校對者:Vivienmm、張拭心
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 給我演示了他在自己的“研發”專案上製作的動畫。我非常喜歡這個動畫,會後決定用程式碼實現它。我可沒想到到我會攤上啥…
GIF 1 “動畫效果”
開始吧!
就像上面 GIF 動畫展示的,需要做的事情有很多。
-
在點選底部選單欄最右方的選單後,我們會跳轉到一個新介面。在此介面中,地圖通過縮放和漸顯的轉場動畫在螢幕上方載入,Recycleview 的 item 隨著轉場動畫從底部載入,地圖上的標記點在轉場動畫執行的同時被新增到地圖上.
-
當滑動底部的 RecycleView item 的時候,地圖上的標記會通過閃爍來顯示它們的位置(譯者注:原文是show their position on the map,個人認為 position 有兩層含義:一代表標記在地圖上的位置,二代表標記所對應的 item 在 RecycleView 裡的位置。)
-
在點選一個 item 以後,我們會進入到新介面。在此介面中,地圖通過動畫方式來顯示出路徑以及起始/結束標記。同時此 RecyclerView 的item 會通過轉場動畫展示一些關於此地點的描述,背景圖片也會放大,還附有更詳細的資訊和一個按鈕。
-
當後退時,詳情頁通過轉場變成普通的 RecycleView Item,所有的地圖示記再次顯示,同時路徑一起消失。
就這麼多啦,這就是我準備在這一系列文章中向你展示的東西。在本文中我會編寫進入地圖 fragment 的轉場動畫。
難點
就像我們在 GIF 1 裡看到的那樣,看起來好像地圖在移動到正確地點之前已經載入完畢了。這在真實世界裡是不可能的,它實際上是這個樣子的:
需求
-
預載入地圖
-
載入完畢後,使用 Google Map API 獲得地圖的快照圖片(bitmap)並儲存在快取中。
-
為地圖編寫一個包含縮放與漸顯的自定義轉場動畫(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 方法裡我們做了三件事:
-
為地圖提供了 LatLngBounds,他們會被用來設定地圖的邊界。
-
在 activity 的佈局里載入了 HomeFragment
-
為 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 類。然後重寫 captureStartValues 和 captureEndValues 方法。猜猜發生了啥?
Transition 框架使用了屬性動畫的 API ,通過改變 view 開始和結束時的屬性值來產生動畫。如果你不熟悉屬性動畫,強烈推薦閱讀這篇文章。就像剛才解釋的那樣,我們要縮放圖片。開始值是 scaleFactor ,結束值是期望 scaleX 和 scaleY的值,通常情況下是1。
怎麼傳遞這些值呢?如前所述,很簡單。我們把 TransitionValues 物件當作引數傳進 captureStart 和 captureEnd 方法裡。它包括一個 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 傳遞進去, 看吧!效果出現啦:
總結
你看,最終效果已經很接近像 GIF 那樣從本地載入地圖的效果了。但是最後一幀動畫仍然會有那麼一點閃爍,因為地圖的快照還是與實際的地圖有點差別。
多謝閱讀,下一部分會在星期二的 7.03 更新。如果有疑問的話,歡迎評論。當然如果發現這些博文很有趣,不要忘記分享噢。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。