Workcation App – Part 1. Fragment custom transition
Welcome to the first of series of posts about my R&D (Research & Development) project I’ve made a while ago. In this blog posts, I want to share my solutions for problems I encountered during the development of an animation idea you’ll see below.
Part 1: Fragment’s custom transition
Part 2: Animating Markers with MapOverlayLayout
Part 3: RecyclerView interaction with Animated Markers
Part 4: Shared Element Transition with RecyclerView and Scenes
Link for project on Github: Workcation App
Link for animation on Dribbble: https://dribbble.com/shots/2881299-Workcation-App-Map-Animation
Prelude
A few months back we’ve had a company meeting, where my friend Paweł Szymankiewicz showed the animation he’d done during his Research & Development. And I loved it. After the meeting, I decided that I will code it. I never knew what I’m going to struggle with…
GIF 1 “The animation”
Let’s start!
As we can see in the GIF above, there is a lot of going on.
- After clicking on the bottom menu item, we are moving to the next screen, where we can see the map being loaded with some scale/fade animation from the top, RecyclerView items loaded with translation from the bottom, markers added to the map with scale/fade animation.
- While scrolling the items in RecyclerView, the markers are pulsing to show their position on the map.
- After clicking on the item, we are transferred to the next screen, the map is animated below to show the route and start/finish marker. The RecyclerView’s item is transitioned to show some description, bigger picture, trip details and button.
- While returning, the transition happens again back to the RecyclerView’s item, all of the markers are shown again, the route disappears.
Pretty much. That’s why I’ve decided to show you all of the things in the series of posts. In this article I will cover the enter animation of the map fragment.
The Problem
As we can see in the GIF 1 above, it looks like the map is already loaded before and just animated to the proper position. This is not happening in the real world. What it really looks like:
The Solution
- Preload map
- When map is loaded, use Google Maps API to get bitmap from it and save it in cache
- Create custom transition for scale and fade effect of the map when entering DetailsFragment
Let’s go!
Preloading the map
To achieve that we have to take bitmap snapshot from already loaded map. Of course we can’t do that in the DetailsFragment if we want the smooth transition between screens. What we have to do, is to get bitmap underneath the HomeFragment and save it in the cache. As you can see, the map have some margin from the bottom, so we also have to fit the “future” map size.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | <?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> |
As you can see in the snippet above, the MapFragment is placed under the rest of the layout. It will allow us to load the map invisible for the user.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | public class MainActivity extends MvpActivity<MainView, MainPresenter> implements MainView, OnMapReadyCallback { SupportMapFragment mapFragment; private LatLngBounds 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 inherits from MvpActivity, which is a class from Mosby Framework created by Hannes Dorfmann. The whole project follows MVP pattern, and the framework I mentioned before is a very nice implementation of it.
In onCreate method we have three things:
- We are providing LatLngBounds for map – they will be used to set the bounds of the map
- We are replacing the HomeFragment in activities container layout
- We are setting the OnMapReadyCallback on the MapFragment
After map is ready, the onMapReady() method is called, and we can do some operations to save the properly loaded map into bitmap. We are moving camera to earlier provided LatLngBounds using CameraUpdateFactory.newLatLngBounds() method. In our case we know exactly what will be the dimension of the map in the next screen, so we are able to pass width(width of screen) and height(height of screen with bottom margin) parameters to this method. We are calculating them like this:
1 2 3 4 5 | public static int calculateWidth(final WindowManager windowManager) { DisplayMetrics metrics = new DisplayMetrics(); windowManager.getDefaultDisplay().getMetrics(metrics); return metrics.widthPixels; } |
1 2 3 4 | public static int calculateHeight(final WindowManager windowManager, final int paddingBottom) { DisplayMetrics metrics = new DisplayMetrics(); windowManager.getDefaultDisplay().getMetrics(metrics); return metrics.heightPixels - paddingBottom; |
Easy. After googleMap.moveCamera() method being called, we are setting the OnMapLoadedCallback. When camera is moved to the desired position, the onMapLoaded() method is called and we are ready to take bitmap from it.
Getting bitmap and saving in cache
The onMapLoaded() method has only one task to do – call presenter.saveBitmap() after snapshot is taken from the map. Thanks to lambda expression, we can reduce boilerplate to simply one line
1 | googleMap.setOnMapLoadedCallback(() -> googleMap.snapshot(presenter::saveBitmap)); |
The presenters code is really simple. It only saves our bitmap in the cache.
1 2 3 4 | @Override public void saveBitmap(final Bitmap bitmap) { MapBitmapCache.instance().putBitmap(bitmap); } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | 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 static MapBitmapCache instance() { if(sInstance == null) { sInstance = new MapBitmapCache(DEFAULT_CACHE_SIZE); return sInstance; } return sInstance; } public Bitmap getBitmap() { return get(KEY); } public void putBitmap(Bitmap bitmap) { put(KEY, bitmap); } @Override protected int sizeOf(String key, Bitmap value) { return value == null ? 0 : value.getRowBytes() * value.getHeight() / 1024; } } |
I’ve used LruCache for it, because it is the recommended way, well described here.
So we have bitmap saved in the cache, the only thing we have to do is to make custom transition for scale and fade effect of the map when entering DetailsFragment. Easy peasy lemon squeezy.
Custom enter transition for fade/scale map effect
So the most interesting part! The code is rather simple, however it allows us to do great stuff.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | 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(new DecelerateInterpolator()); } 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 int factor) { 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); } } |
What we do in this transition is that we are scaling down the scaleX and scaleY properties from ImageView from scaleFactor(default is 8) to desired view scale. So in other words we are increasing the width and height by scaleFactor in the first place, and then we are scaling it down to desired size.
Creating custom transition
In order to do the custom transition, we have to inherit from Transition class. The next step is to override the captureStartValues and captureEndValues. What is happening here?
The Transition Framework is using the Property Animation API to animate between view’s start and end property value. If you are not familiar with this, you should definetely read this article. As explained before, we want to scale down our image. So the startValue is our scaleFactor, and endValue is the desired scaleX and scaleY – normally it will be 1.
How to pass those values? As said before – easy. We have TransitionValues object passed as argument in both captureStart and captureEnd methods. It contains a reference to the view and a map in which you can store the view values – in our case the scaleX and scaleY.
With values captured, we need to override the createAnimator() method. In this method we are returning the Animator (or AnimatorSet) object which animates changes between view property values. So in our case we are returning the AnimatorSet which will animate the scale and alpha of the view. Also we want our transition to work only for ImageView, so we check if view reference from TransitionValues object passed as argument is ImageView instance.
Applying custom transition
We have bitmap stored in memory, we have transition created, so we have last step – applying the transition to our fragment. I like to create static factory method for creating the fragments and activities. It looks really nice and helps us to keep the code rather clean. It is also the nice idea to put our Transition there programmatically.
1 2 3 4 5 6 7 8 | 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; } |
As we can see it is really easy to do. We create new instance of our transition, we add target here and also in XML of the target view, via transitionName attribute.
1 2 3 4 5 6 | <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"/> |
Next we just pass transition to fragment via setEnterTransition() method and voila! There is the effect:
Conclusion
As you can see, the final result is closer to the original from the GIF than native map loading. There is also a glitch in the final phase of the animation – this is because the snapshot from the map is different than the content of the SupportMapFragment.
Thanks for reading! Next part will be published on Tuesday 7.03. Feel free to leave a comment if you have any questions, and if you found this blog post helpful – don’t forget to share it!
About the author
Ready to take your business to the next level with a digital product?
We'll be with you every step of the way, from idea to launch and beyond!
Great Post! But I got NPE error in running the demo. Looks like the startValues is Null.
java.lang.NullPointerException: Attempt to read from field ‘java.util.Map android.transition.TransitionValues.values’ on a null object reference
at com.droidsonroids.workcation.common.transitions.ScaleDownImageTransition.createAnimator(ScaleDownImageTransition.java:66)
Please add issue to the repository and I’ll look into it as soon as possible 😉