Adetunji Dahunsi

Composition over Inheritance; Adding a Material Speed Dial to a Floating Action Button

Material Floating Action Buttons

TJ Dahunsi

Nov 16 2019 · 9 min read

Material Design is an excellent design and visual language on Android, it is also very ambitious, which makes the actual implementation of its guidelines without dropping any of the visual flair a daunting exercise. To this end, the Android community relies on multiple 3rd party libraries and codes samples that provide implementations of these Material patterns, however most of them require inheritance on a customView class that makes it difficult to add more attributes to.

Take for instance, your UI/UX designer initially requests that the FloatingActionButton in your application have a speed dial behavior on a particular screen. To do this, you have a couple of fantastic libraries to choose from:

You pick either of those 2, and you get exactly what is promised; excellent feature rich libraries with well designed APIs. Things are going great till your UI / UX designer decides that under some arcane condition, the FloatingActionButton should first be expanded when the user first enters the screen, then collapse after 2 seconds and resume its speed dial behavior. Furthermore, if the FAB is extended when the user clicks it, it should collapse first, then show the speed dial.

This poses a challenge because these libraries require you to use their own custom Views that are more or less black boxes as far as their standard APIs are concerned. You’d need to peer into their internals to see what’s actually going on and see if you can work around whatever limitations you may have.

This is the classic argument against inheritance for adding extra functionality to a class. None of the elements in a speed dial actually require you to extend from the View whose click should trigger the speed dial. The speed dial rather should either be a function, or independent class which you can call on to get the desired behavior, completely independent of the View clicked.

That being said, let’s go about creating a FAB speed dial that is completely independent of the anchor View for the speed dial action. The requirements are:

  1. The speed dial should work independent of the View subclass of it’s anchor

  2. The anchor View needs to meet the requirement that it initially appears expanded, then collapses itself along with collapsing itself when clicked, and subsequently showing the speed dial.

Fortunately, the Material Components for Android provide an excellent base to build upon for sort of thing. In this case, the anchor View to provide the behavior shall be the [MaterialButton](https://github.com/material-components/material-components-android/blob/master/docs/components/MaterialButton.md). It may be tempting to go with the [ExtendedFloatingActionButton](https://github.com/material-components/material-components-android/blob/master/docs/components/ExtendedFloatingActionButton.md) as it gives expanding behavior out of the box, unfortunately it suffers the same inheritance problems outlined earlier. Expanding and collapsing behavior can be composed independent of the MaterialButton. The implementation of ExtendedFloatingActionButton actually subclasses MaterialButton internally to build upon, which makes the original [FloatingActionButton](https://github.com/material-components/material-components-android/blob/master/docs/components/FloatingActionButton.md) class rather duplicitous and redundant. We hope to achieve something close to the below:

The goal The goal

The crux for the speed dial behavior will be Android’s PopupWindow class which is amazing for displaying an arbitrary View in a window and having full control over its positioning. A Dialog is a bit clunky in this regard as it does a lot and wraps your custom View in other Views that inadvertently mess up the positioning you’d prefer. First, we define a method that lets you pop any View over any other View:

1/** 2 * Pops an orphaned [View] over the specified [anchor] using a [PopupWindow] 3 */ 4fun View.popOver( 5 anchor: View, 6 adjuster: () -> Point = { Point(0, 0) }, 7 options: PopupWindow.() -> Unit = {} 8) { 9 require(!this.isAttachedToWindow) { "The View being attached must be an orphan" } 10 PopupWindow(this.wrapAtAnchor(anchor, adjuster), MATCH_PARENT, MATCH_PARENT, true).run { 11 isOutsideTouchable = true 12 contentView.setOnTouchListener { _, _ -> dismiss(); true } 13 options(this) 14 showAtLocation(anchor, Gravity.START, 0, 0) 15 } 16} 17 18private fun View.wrapAtAnchor(anchor: View, adjuster: () -> Point): View? = FrameLayout(anchor.context).apply { 19 this@wrapAtAnchor.alignToAnchor(anchor, adjuster) 20 addView(this@wrapAtAnchor, ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) 21} 22 23private fun View.alignToAnchor(anchor: View, adjuster: () -> Point) = intArrayOf(0, 0).run { 24 anchor.getLocationInWindow(this) 25 doOnLayout { 26 val (offsetX, offsetY) = adjuster() 27 val x = this[0].toFloat() + offsetX 28 val y = this[1].toFloat() + offsetY 29 translationX = x; translationY = y 30 } 31}

This method creates a wrapper FrameLayout that fits the whole screen, and adds the View to be popped over the anchor relative to the anchor only after it has been measured. This is necessary because the speed dial can have an arbitrary height and width, and these values are needed for pixel perfect placement after layout.

With the above, we can define the pop up layout and show it relative to the anchor. Since the speed dial should not have more than 5 items, a LinearLayout with dynamically added views will suffice.

1private const val MIRROR = 180F 2private const val INITIAL_DELAY = 0.15F 3 4private val translucentBlack = Color.argb(50, 0, 0, 0) 5 6fun speedDial( 7 anchor: View, 8 @ColorInt tint: Int = anchor.context.themeColorAt(R.attr.colorPrimary), 9 @StyleRes animationStyle: Int = android.R.style.Animation_Dialog, 10 layoutAnimationController: LayoutAnimationController = LayoutAnimationController(speedDialAnimation, INITIAL_DELAY).apply { order = ORDER_NORMAL }, 11 items: List<Pair<CharSequence?, Drawable>>, 12 dismissListener: (Int?) -> Unit 13) = LinearLayout(anchor.context).run root@{ 14 rotationY = MIRROR 15 rotationX = MIRROR 16 clipChildren = false 17 clipToPadding = false 18 orientation = VERTICAL 19 layoutAnimation = layoutAnimationController 20 21 popOver(anchor = anchor, adjuster = getOffset(anchor)) popUp@{ 22 this.animationStyle = animationStyle 23 24 var dismissReason: Int? = null 25 setOnDismissListener { dismissListener(dismissReason) } 26 27 items.mapIndexed { index, pair -> speedDialLayout(pair, tint, View.OnClickListener { dismissReason = index; dismiss() }) } 28 .forEach(this@root::addView) 29 } 30} 31 32private fun LinearLayout.speedDialLayout(pair: Pair<CharSequence?, Drawable>, tint: Int, clickListener: View.OnClickListener) = LinearLayout(context).apply { 33 rotationY = MIRROR 34 rotationX = MIRROR 35 clipChildren = false 36 clipToPadding = false 37 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) 38 39 updatePadding(bottom = context.resources.getDimensionPixelSize(R.dimen.single_margin)) 40 setOnClickListener(clickListener) 41 42 addView(speedDialLabel(tint, pair.first, clickListener)) 43 addView(speedDialFab(tint, pair, clickListener)) 44} 45 46private fun LinearLayout.speedDialLabel(tint: Int, label: CharSequence?, clicker: View.OnClickListener) = AppCompatTextView(context).apply { 47 val dp4 = context.resources.getDimensionPixelSize(R.dimen.quarter_margin) 48 val dp8 = context.resources.getDimensionPixelSize(R.dimen.half_margin) 49 50 isClickable = true 51 background = context.ripple(tint) { setAllCornerSizes(dp8.toFloat()) } 52 53 isVisible = label != null 54 text = label 55 56 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { 57 marginEnd = context.resources.getDimensionPixelSize(R.dimen.single_margin) 58 gravity = Gravity.CENTER_VERTICAL 59 } 60 61 updatePadding(left = dp8, top = dp4, right = dp8, bottom = dp4) 62 setOnClickListener(clicker) 63} 64 65private fun LinearLayout.speedDialFab(tint: Int, pair: Pair<CharSequence?, Drawable>, clicker: View.OnClickListener) = AppCompatImageButton(context).apply { 66 val dp40 = context.resources.getDimensionPixelSize(R.dimen.double_and_half_margin) 67 68 imageTintList = null 69 background = context.ripple(tint) { setAllCornerSizes(dp40.toFloat()) } 70 layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { 71 gravity = Gravity.CENTER_VERTICAL 72 height = dp40 73 width = dp40 74 } 75 76 setOnClickListener(clicker) 77 setImageDrawable(if (pair.second !is BitmapDrawable) BitmapDrawable(context.resources, pair.second.toBitmap()) else pair.second) 78} 79 80private fun View.getOffset(anchor: View): () -> Point = { 81 val dp40 = context.resources.getDimensionPixelSize(R.dimen.double_and_half_margin) 82 val halfAnchorWidth = (anchor.width / 2) 83 val halfMiniFabWidth = (dp40 / 2) 84 val xOffset = if (width > anchor.width) halfAnchorWidth + halfMiniFabWidth - width else halfAnchorWidth - halfMiniFabWidth 85 86 Point(xOffset, -(anchor.height / 2) - height) 87} 88 89private fun Context.ripple(tint: Int, shapeModifier: ShapeAppearanceModel.Builder.() -> Unit): RippleDrawable = RippleDrawable( 90 ColorStateList.valueOf(translucentBlack), 91 MaterialShapeDrawable(ShapeAppearanceModel.builder().run { 92 shapeModifier(this) 93 build() 94 }).apply { 95 tintList = ColorStateList.valueOf(tint) 96 setShadowColor(Color.DKGRAY) 97 initializeElevationOverlay(this@ripple) 98 }, 99 null

Notice that the parent LinearLayout mirrors itself around both the x and y axes. This is because the LinearLayout stacks children from top to bottom and left to right. For a speed dial however, the inverse is required, children items stack from bottom to top, and are right aligned. Rather than deal with LayoutParams and gravity for child views, it’s easier to just mirror the parent, and the children displayed within it.

On the animation front, there are two things going on:

  1. The pop up window fade in animation: defaults to the system provided android.R.style.*Animation_Dialog. *This can be overriden if desired.

  2. The speed lateral translation of each item in the speed dial which is set using the layoutAnnimation property of the parent LinearLayout, which is also user customizable. The LinearLayout will run this animation on each child that is added to it.

1private val speedDialAnimation: Animation 2 get() = AnimationSet(false).apply { 3 duration = 200L 4 addAnimation(alpha()) 5 addAnimation(scale()) 6 addAnimation(translate()) 7 } 8 9private fun alpha() = AlphaAnimation(0F, 1F).accelerateDecelerate() 10 11private fun translate(): Animation = TranslateAnimation( 12 RELATIVE_TO_PARENT, 13 0F, 14 RELATIVE_TO_PARENT, 15 0F, 16 RELATIVE_TO_PARENT, 17 SPEED_DIAL_TRANSLATION_Y, 18 RELATIVE_TO_PARENT, 19 0F 20).accelerateDecelerate() 21 22private fun scale(): Animation = ScaleAnimation( 23 SPEED_DIAL_SCALE, 24 1F, 25 SPEED_DIAL_SCALE, 26 1F, 27 RELATIVE_TO_SELF, 28 SPEED_DIAL_SCALE_PIVOT, 29 RELATIVE_TO_SELF, 30 SPEED_DIAL_SCALE_PIVOT 31).accelerateDecelerate() 32 33private const val SPEED_DIAL_TRANSLATION_Y = -0.2F 34private const val SPEED_DIAL_SCALE_PIVOT = 0.5F 35private const val SPEED_DIAL_SCALE = 0.5F 36 37private fun Animation.accelerateDecelerate() = apply { interpolator = AccelerateDecelerateInterpolator() }

At this point all that is left is to animate the rotation of the MaterialButton and to give a halo as it opens up the speed dial. This is the most involved part, and also the part that may need the most customization, and as such, is outside the core behavior to show a speed dial.

In the following implementation, the entry point is a View.OnClickListener that can be attached to any View. The moment the View is clicked, the instance verifies the View clicked is a MaterialButton and is in a state to run, then goes ahead to animate the, stroke, rotation and transparency to give the desired effect. It knows nothing of the expanding or collapsing behabior of the FAB. Externally, if the MaterialButton is an [ExtendedFloatingActionButton](https://github.com/material-components/material-components-android/blob/master/docs/components/ExtendedFloatingActionButton.md), a callback can be attached to it to call performClick on itself after it the collapse has finished animating. In my case, I do the same with a custom class I wrote called a [FabExtensionAnimator](https://github.com/tunjid/Android-Extensions/blob/develop/material/src/main/java/com/tunjid/androidx/material/animator/FabExtensionAnimator.kt).

1class SpeedDialClickListener( 2 @ColorInt private val tint: Int = Color.BLUE, 3 private val items: List<Pair<CharSequence?, Drawable>>, 4 private val runGuard: (View) -> Boolean, 5 private val dismissListener: (Int?) -> Unit 6) : View.OnClickListener { 7 8 override fun onClick(button: View?) { 9 if (button !is MaterialButton || !runGuard(button)) return 10 11 val rotationSpring = button.spring(DynamicAnimation.ROTATION) 12 if (rotationSpring.isRunning) return 13 14 val flipRange = 90F..180F 15 val context = button.context 16 val colorFrom = context.themeColorAt(R.attr.colorPrimary) 17 val colorTo = context.themeColorAt(R.attr.colorAccent).run { 18 Color.argb(20, Color.red(this), Color.green(this), Color.blue(this)) 19 } 20 21 button.strokeColor = ColorStateList.valueOf(context.themeColorAt(R.attr.colorAccent)) 22 23 rotationSpring.apply { 24 doInRange(flipRange) { button.icon = button.context.drawableAt(R.drawable.ic_unfold_less_24dp) } 25 animateToFinalPosition(225F) // re-targeting will be idempotent 26 } 27 28 val animators = button.haloEffects(colorFrom, colorTo, context) 29 30 speedDial(anchor = button, tint = tint, items = items, dismissListener = dismiss@{ index -> 31 animators.forEach(ValueAnimator::cancel) 32 33 rotationSpring.apply { 34 if (!isRunning) { 35 doInRange(flipRange) { button.icon = button.context.drawableAt(R.drawable.ic_unfold_more_24dp) } 36 withOneShotEndListener { dismissListener(index) } 37 } 38 39 animateToFinalPosition(0F) 40 } 41 42 if (index == null) button.haloEffects(colorFrom, colorTo, context) 43 }) 44 } 45 46 private fun MaterialButton.haloEffects(colorFrom: Int, colorTo: Int, context: Context) = listOf( 47 roundAbout(colorFrom, colorTo, ArgbEvaluator(), { backgroundTintList!!.defaultColor }) { backgroundTintList = ColorStateList.valueOf(it as Int) }, 48 roundAbout(0, context.resources.getDimensionPixelSize(R.dimen.quarter_margin), IntEvaluator(), this::getStrokeWidth, this::setStrokeWidth) 49 ) 50 51 private inline fun <reified T> roundAbout( 52 originalPosition: T, 53 nextPosition: T, 54 evaluator: TypeEvaluator<T>, 55 crossinline getter: () -> T, 56 crossinline setter: (T) -> Unit 57 ) = ValueAnimator.ofObject(evaluator, getter(), nextPosition).apply { 58 duration = 200L 59 addUpdateListener { setter(it.animatedValue as T) } 60 doOnEnd { 61 setObjectValues(getter(), originalPosition) 62 removeAllListeners() 63 start() 64 } 65 start() 66 } 67 68 private fun SpringAnimation.doInRange(range: ClosedRange<Float>, action: () -> Unit) = apply { 69 var flipped = false 70 withOneShotUpdateListener update@{ value, _ -> 71 if (flipped || !range.contains(value)) return@update 72 action() 73 flipped = true 74 } 75 } 76}

With the above, the output looks like this:

Implementation of an expanding FAB collapsing to a speed dial Implementation of an expanding FAB collapsing to a speed dial

With the above, the speed dial is completely independent of whatever View is attached to. If a new Material Design spec came out, the behavior can be easily moved to any View with no overhead. In the case where both the extension of the FAB and the speed dial are both independent of the FAB, things are even better as there’s a much higher separation of concerns.

The implementation in the host fragment follows:

1class SpeedDialFragment : AppBaseFragment(R.layout.fragment_speed_dial) { 2 3 private val color 4 get() = if (requireContext().isDarkTheme) Color.BLACK else Color.WHITE 5 6 override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 7 super.onViewCreated(view, savedInstanceState) 8 9 val context = view.context 10 11 uiState = uiState.copy( 12 toolbarTitle = this::class.java.routeName, 13 toolbarShows = true, 14 toolBarMenu = 0, 15 fabShows = true, 16 fabExtended = true, 17 fabText = getString(R.string.speed_dial), 18 fabIcon = R.drawable.ic_unfold_more_24dp, 19 showsBottomNav = false, 20 lightStatusBar = !context.isDarkTheme, 21 navBarColor = context.themeColorAt(R.attr.nav_bar_color), 22 fabClickListener = SpeedDialClickListener( 23 tint = context.themeColorAt(R.attr.colorAccent), 24 items = speedDialItems(), 25 runGuard = this@SpeedDialFragment::fabExtensionGuard, 26 dismissListener = this@SpeedDialFragment::onSpeedDialClicked 27 ) 28 ) 29 30 view.postDelayed(2000) { if (isResumed) uiState = uiState.copy(fabExtended = false) } 31 } 32 33 private fun speedDialItems(): List<Pair<CharSequence?, Drawable>> = requireActivity().run { 34 listOf( 35 getString(R.string.expand_fab).color(color) to drawableAt(R.drawable.ic_expand_24dp) 36 ?.withTint(color)!!, 37 getString(R.string.option_1).color(color) to drawableAt(R.drawable.ic_numeric_1_outline_24dp) 38 ?.withTint(color)!!, 39 getString(R.string.option_2).color(color) to drawableAt(R.drawable.ic_numeric_2_outline_24dp) 40 ?.withTint(color)!!) 41 } 42 43 private fun onSpeedDialClicked(it: Int?) = when (it) { 44 0 -> uiState = uiState.copy(fabExtended = true) 45 else -> Unit 46 } 47 48 private fun fabExtensionGuard(view: View): Boolean { 49 if (!uiState.fabExtended) return true 50 uiState = uiState.copy( 51 fabExtended = false, 52 fabTransitionOptions = { speedDialRecall(view) } 53 ) 54 return false 55 } 56 57 private fun Transition.speedDialRecall(view: View) = doOnEnd { 58 if (uiState.fabExtended) return@doOnEnd 59 uiState.fabClickListener?.onClick(view) 60 uiState = uiState.copy(fabTransitionOptions = null) 61 } 62 63 companion object { 64 fun newInstance(): SpeedDialFragment = SpeedDialFragment().apply { arguments = Bundle() } 65 } 66}

The adage rings true, whenever you can, favor composition over inheritance.

You can find the sample code for this post in it’s entirety here:

2