Workcation App – Part 2. Animating Markers with MapOverlayLayout
Welcome to the second 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 map loading and mysterious MapWrapperLayout. Stay tuned!
The Problem
So the next step in my development was to load the map to show all of the markers provided by the “API” (simple singleton parsing JSON from assets). Fortunately, it’s been already described in the previous post. The second thing we need to do is to load markers with fade/scale animation. Easy, right? Not really.
Unfortunately, the Google Maps API only allows us to pass BitmapDescriptor as an icon of the Marker. This is how it’s done:
1 2 3 4 5 6 7 | GoogleMap map = ... // get a map. // Add a marker at San Francisco with an azure colored marker. 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)); |
As you can see on the animation gif, we have to implement scale/fade in animation on loading, scale up/scale down animation while scrolling RecyclerView and fade out when entering details layout. It would be much easier with the Animation/ViewPropertyAnimator API available. Do we have a solution for that? Yes we have!
MapOverlayLayout
So what is the solution? It’s rather simple, however, it took me a while to figure this out. We need to add a MapOverlayLayout over the SupportMapFragment from GoogleMapsApi. With a projection pulled out of the map (a projection is used to translate between on-screen location and geographic coordinates on the surface of the Earth, via
MapOverlayLayout is a custom FrameLayout with the same dimensions as the SupportMapFragment. When the map is loaded, we can pass a reference to the MapOverlayLayout and use it to add custom views with animation, move them along with the screen gestures, etc. And of course, we can do what we need – add Markers (now the custom views) with scale/fade animation, hide them, make the “pulse” animation while scrolling RecyclerView.
MapOverlayLayout – the setup
So how to setup the MapOverlayLayout to cooperate with SupportMapFragment and GoogleMap?
First, let’s look at the DetailsFragment XML:
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 | <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> |
As we can see above, there is a PulseOverlayLayout with same dimensions as SupportMapFragment placed underneath. The PulseOverlayLayout inherits from the MapOverlayLayout and adds some specific logic for the app purpose (for example adding start and finish markers to the layout after clicking on the RecyclerView item, creating PulseMarkerView – custom views I will describe later in the post). There is also an ImageView inside the layout – that’s the placeholder we use to create fragment enter transition I described here. And that’s all the work for XML! Let’s move on to another piece 0f code – the DetailsFragment itself.
Let’s move on to another piece 0f code – the DetailsFragment itself.
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 | 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); } } |
As we can see above – the map is loaded exactly the same as in the previous article, with onMapReady method. After receiving this callback, we are able to update maps bounds, add markers to MapOverlayLayout and set proper listeners.
In the following code, we are moving the camera to the bounds that will show us all of the markers. Next, when camera finishes moving, we are creating markers and showing them on the map. After that, we set OnCameraIdleListener to null. It is because we don’t want to add markers when we move the camera. In the last line of code, we are setting OnCameraMoveListener to refresh all of the Markers positions on the screen.
1 2 3 4 5 6 7 8 9 10 11 | @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 – how does it work?
So how it actually works?
With a projection pulled out of the map (a projection is used to translate between on-screen location and geographic coordinates on the surface of the Earth, via documentation) we are able to get x and y values of the Marker and use them to place Custom View in the place of the Marker on the MapOverlayLayout.
This approach allows us to use f.e. ViewPropertyAnimator API with custom views to animate them.
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 | 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 = new ArrayList<>(); } 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)); } } |
The methods used in the moveMapAndAddMarker method in DetailsFragment are visible above. We can see setters for CameraListeners, refresh method for updating the Marker position on the MapOverlayLayout; addMarker and removeMarker methods, which are adding MarkerView to the layout and also to the list. With this approach, the MapOverlayLayout have references to all of the views added to the MapOverlayLayout. At the top of the class, we can see that we have to make our custom views to inherit from the MarkerView. It is an abstract class that inherits from View class and looks like this:
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 | 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 void show(); public abstract void hide(); public abstract void refresh(final Point point); } |
With show, hide and refresh abstract methods we can specify the way the Marker will appear, disappear or refresh. It also needs the Context, LatLng and Point. Let’s look into our implementation:
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 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | 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(new AnimatorListenerAdapter() { @Override public void onAnimationStart(final Animator animation) { super.onAnimationStart(animation); setVisibility(View.INVISIBLE); invalidate(); } }); hideAnimatorSet = new AnimatorSet(); hideAnimatorSet.playTogether(animator, animatorScaleX, animatorScaleY); } private void setupSizes(final Context 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(new AnimatorListenerAdapter() { @Override public void onAnimationStart(final Animator animation) { super.onAnimationStart(animation); setVisibility(View.VISIBLE); invalidate(); } }); showAnimatorSet = new AnimatorSet(); 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 = new Paint(); 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 = new Paint(); 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 = new Paint(); 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 = new FrameLayout.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(String text) { this.text = text; invalidate(); } @Override public void hide() { hideAnimatorSet.start(); } @Override public void refresh(final Point 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 = new FrameLayout.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(); } } |
This is PulseMarkerView class which inherits from MarkerView. In the constructor, we are setting up the AnimatorSets for showing, hiding and “pulsing”. In overridden methods from MarkerView, we are simply starting specific AnimatorSet. There is also updatePulseViewLayoutParams method which updates the position of the PulseViewMarker on the screen. The rest is drawing on the canvas with Paints created in the constructor.
This is the effect:
Loading Markers and scaling on scroll
Refreshing the markers position while moving the map
Zooming the map
Zooming the map and scaling Markers while scrolling
Conclusion
As we can see, there is a big advantage from this approach – we can use the power of the Custom Views widely. Also, there is a very little delay when we move the map and refresh Markers position. I think it is a little price we have to pay compared to the advantages we have from this solution.
Thanks for reading! Next part will be published on Tuesday 14.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!
Nice.
Have they added a way, though, to show the entire world map ?
I tried various zoom values, and none worked.
It could be useful to show entire world with markers on it, showing global events.
Hey!
Sorry for waiting. I haven’t tried it but my first guess is to update camera to LatLng which are extreme coordinates for Earth. But it’s just wild guess.
I’m pretty sure I tried it. Can you please try too?