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

TJ Dahunsi
Nov 16 2019 · 9 mins
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:
The speed dial should work independent of the
View
subclass of it’s anchorThe 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 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
:
/** * Pops an orphaned [View] over the specified [anchor] using a [PopupWindow] */ fun View.popOver( anchor: View, adjuster: () -> Point = { Point(0, 0) }, options: PopupWindow.() -> Unit = {} ) { require(!this.isAttachedToWindow) { "The View being attached must be an orphan" } PopupWindow(this.wrapAtAnchor(anchor, adjuster), MATCH_PARENT, MATCH_PARENT, true).run { isOutsideTouchable = true contentView.setOnTouchListener { _, _ -> dismiss(); true } options(this) showAtLocation(anchor, Gravity.START, 0, 0) } } private fun View.wrapAtAnchor(anchor: View, adjuster: () -> Point): View? = FrameLayout(anchor.context).apply { this@wrapAtAnchor.alignToAnchor(anchor, adjuster) addView(this@wrapAtAnchor, ViewGroup.LayoutParams(WRAP_CONTENT, WRAP_CONTENT)) } private fun View.alignToAnchor(anchor: View, adjuster: () -> Point) = intArrayOf(0, 0).run { anchor.getLocationInWindow(this) doOnLayout { val (offsetX, offsetY) = adjuster() val x = this[0].toFloat() + offsetX val y = this[1].toFloat() + offsetY translationX = x; translationY = y } }
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.
private const val MIRROR = 180F private const val INITIAL_DELAY = 0.15F private val translucentBlack = Color.argb(50, 0, 0, 0) fun speedDial( anchor: View, @ColorInt tint: Int = anchor.context.themeColorAt(R.attr.colorPrimary), @StyleRes animationStyle: Int = android.R.style.Animation_Dialog, layoutAnimationController: LayoutAnimationController = LayoutAnimationController(speedDialAnimation, INITIAL_DELAY).apply { order = ORDER_NORMAL }, items: List<Pair<CharSequence?, Drawable>>, dismissListener: (Int?) -> Unit ) = LinearLayout(anchor.context).run root@{ rotationY = MIRROR rotationX = MIRROR clipChildren = false clipToPadding = false orientation = VERTICAL layoutAnimation = layoutAnimationController popOver(anchor = anchor, adjuster = getOffset(anchor)) popUp@{ this.animationStyle = animationStyle var dismissReason: Int? = null setOnDismissListener { dismissListener(dismissReason) } items.mapIndexed { index, pair -> speedDialLayout(pair, tint, View.OnClickListener { dismissReason = index; dismiss() }) } .forEach(this@root::addView) } } private fun LinearLayout.speedDialLayout(pair: Pair<CharSequence?, Drawable>, tint: Int, clickListener: View.OnClickListener) = LinearLayout(context).apply { rotationY = MIRROR rotationX = MIRROR clipChildren = false clipToPadding = false layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT) updatePadding(bottom = context.resources.getDimensionPixelSize(R.dimen.single_margin)) setOnClickListener(clickListener) addView(speedDialLabel(tint, pair.first, clickListener)) addView(speedDialFab(tint, pair, clickListener)) } private fun LinearLayout.speedDialLabel(tint: Int, label: CharSequence?, clicker: View.OnClickListener) = AppCompatTextView(context).apply { val dp4 = context.resources.getDimensionPixelSize(R.dimen.quarter_margin) val dp8 = context.resources.getDimensionPixelSize(R.dimen.half_margin) isClickable = true background = context.ripple(tint) { setAllCornerSizes(dp8.toFloat()) } isVisible = label != null text = label layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { marginEnd = context.resources.getDimensionPixelSize(R.dimen.single_margin) gravity = Gravity.CENTER_VERTICAL } updatePadding(left = dp8, top = dp4, right = dp8, bottom = dp4) setOnClickListener(clicker) } private fun LinearLayout.speedDialFab(tint: Int, pair: Pair<CharSequence?, Drawable>, clicker: View.OnClickListener) = AppCompatImageButton(context).apply { val dp40 = context.resources.getDimensionPixelSize(R.dimen.double_and_half_margin) imageTintList = null background = context.ripple(tint) { setAllCornerSizes(dp40.toFloat()) } layoutParams = LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.CENTER_VERTICAL height = dp40 width = dp40 } setOnClickListener(clicker) setImageDrawable(if (pair.second !is BitmapDrawable) BitmapDrawable(context.resources, pair.second.toBitmap()) else pair.second) } private fun View.getOffset(anchor: View): () -> Point = { val dp40 = context.resources.getDimensionPixelSize(R.dimen.double_and_half_margin) val halfAnchorWidth = (anchor.width / 2) val halfMiniFabWidth = (dp40 / 2) val xOffset = if (width > anchor.width) halfAnchorWidth + halfMiniFabWidth - width else halfAnchorWidth - halfMiniFabWidth Point(xOffset, -(anchor.height / 2) - height) } private fun Context.ripple(tint: Int, shapeModifier: ShapeAppearanceModel.Builder.() -> Unit): RippleDrawable = RippleDrawable( ColorStateList.valueOf(translucentBlack), MaterialShapeDrawable(ShapeAppearanceModel.builder().run { shapeModifier(this) build() }).apply { tintList = ColorStateList.valueOf(tint) setShadowColor(Color.DKGRAY) initializeElevationOverlay(this@ripple) }, 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:
The pop up window fade in animation: defaults to the system provided
android.R.style.*Animation_Dialog
. *This can be overriden if desired.The speed lateral translation of each item in the speed dial which is set using the
layoutAnnimation
property of the parentLinearLayout
, which is also user customizable. TheLinearLayout
will run this animation on each child that is added to it.
private val speedDialAnimation: Animation get() = AnimationSet(false).apply { duration = 200L addAnimation(alpha()) addAnimation(scale()) addAnimation(translate()) } private fun alpha() = AlphaAnimation(0F, 1F).accelerateDecelerate() private fun translate(): Animation = TranslateAnimation( RELATIVE_TO_PARENT, 0F, RELATIVE_TO_PARENT, 0F, RELATIVE_TO_PARENT, SPEED_DIAL_TRANSLATION_Y, RELATIVE_TO_PARENT, 0F ).accelerateDecelerate() private fun scale(): Animation = ScaleAnimation( SPEED_DIAL_SCALE, 1F, SPEED_DIAL_SCALE, 1F, RELATIVE_TO_SELF, SPEED_DIAL_SCALE_PIVOT, RELATIVE_TO_SELF, SPEED_DIAL_SCALE_PIVOT ).accelerateDecelerate() private const val SPEED_DIAL_TRANSLATION_Y = -0.2F private const val SPEED_DIAL_SCALE_PIVOT = 0.5F private const val SPEED_DIAL_SCALE = 0.5F private 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).
class SpeedDialClickListener( @ColorInt private val tint: Int = Color.BLUE, private val items: List<Pair<CharSequence?, Drawable>>, private val runGuard: (View) -> Boolean, private val dismissListener: (Int?) -> Unit ) : View.OnClickListener { override fun onClick(button: View?) { if (button !is MaterialButton || !runGuard(button)) return val rotationSpring = button.spring(DynamicAnimation.ROTATION) if (rotationSpring.isRunning) return val flipRange = 90F..180F val context = button.context val colorFrom = context.themeColorAt(R.attr.colorPrimary) val colorTo = context.themeColorAt(R.attr.colorAccent).run { Color.argb(20, Color.red(this), Color.green(this), Color.blue(this)) } button.strokeColor = ColorStateList.valueOf(context.themeColorAt(R.attr.colorAccent)) rotationSpring.apply { doInRange(flipRange) { button.icon = button.context.drawableAt(R.drawable.ic_unfold_less_24dp) } animateToFinalPosition(225F) // re-targeting will be idempotent } val animators = button.haloEffects(colorFrom, colorTo, context) speedDial(anchor = button, tint = tint, items = items, dismissListener = dismiss@{ index -> animators.forEach(ValueAnimator::cancel) rotationSpring.apply { if (!isRunning) { doInRange(flipRange) { button.icon = button.context.drawableAt(R.drawable.ic_unfold_more_24dp) } withOneShotEndListener { dismissListener(index) } } animateToFinalPosition(0F) } if (index == null) button.haloEffects(colorFrom, colorTo, context) }) } private fun MaterialButton.haloEffects(colorFrom: Int, colorTo: Int, context: Context) = listOf( roundAbout(colorFrom, colorTo, ArgbEvaluator(), { backgroundTintList!!.defaultColor }) { backgroundTintList = ColorStateList.valueOf(it as Int) }, roundAbout(0, context.resources.getDimensionPixelSize(R.dimen.quarter_margin), IntEvaluator(), this::getStrokeWidth, this::setStrokeWidth) ) private inline fun <reified T> roundAbout( originalPosition: T, nextPosition: T, evaluator: TypeEvaluator<T>, crossinline getter: () -> T, crossinline setter: (T) -> Unit ) = ValueAnimator.ofObject(evaluator, getter(), nextPosition).apply { duration = 200L addUpdateListener { setter(it.animatedValue as T) } doOnEnd { setObjectValues(getter(), originalPosition) removeAllListeners() start() } start() } private fun SpringAnimation.doInRange(range: ClosedRange<Float>, action: () -> Unit) = apply { var flipped = false withOneShotUpdateListener update@{ value, _ -> if (flipped || !range.contains(value)) return@update action() flipped = true } } }
With the above, the output looks like this:
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:
class SpeedDialFragment : AppBaseFragment(R.layout.fragment_speed_dial) { private val color get() = if (requireContext().isDarkTheme) Color.BLACK else Color.WHITE override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val context = view.context uiState = uiState.copy( toolbarTitle = this::class.java.routeName, toolbarShows = true, toolBarMenu = 0, fabShows = true, fabExtended = true, fabText = getString(R.string.speed_dial), fabIcon = R.drawable.ic_unfold_more_24dp, showsBottomNav = false, lightStatusBar = !context.isDarkTheme, navBarColor = context.themeColorAt(R.attr.nav_bar_color), fabClickListener = SpeedDialClickListener( tint = context.themeColorAt(R.attr.colorAccent), items = speedDialItems(), runGuard = this@SpeedDialFragment::fabExtensionGuard, dismissListener = this@SpeedDialFragment::onSpeedDialClicked ) ) view.postDelayed(2000) { if (isResumed) uiState = uiState.copy(fabExtended = false) } } private fun speedDialItems(): List<Pair<CharSequence?, Drawable>> = requireActivity().run { listOf( getString(R.string.expand_fab).color(color) to drawableAt(R.drawable.ic_expand_24dp) ?.withTint(color)!!, getString(R.string.option_1).color(color) to drawableAt(R.drawable.ic_numeric_1_outline_24dp) ?.withTint(color)!!, getString(R.string.option_2).color(color) to drawableAt(R.drawable.ic_numeric_2_outline_24dp) ?.withTint(color)!!) } private fun onSpeedDialClicked(it: Int?) = when (it) { 0 -> uiState = uiState.copy(fabExtended = true) else -> Unit } private fun fabExtensionGuard(view: View): Boolean { if (!uiState.fabExtended) return true uiState = uiState.copy( fabExtended = false, fabTransitionOptions = { speedDialRecall(view) } ) return false } private fun Transition.speedDialRecall(view: View) = doOnEnd { if (uiState.fabExtended) return@doOnEnd uiState.fabClickListener?.onClick(view) uiState = uiState.copy(fabTransitionOptions = null) } companion object { fun newInstance(): SpeedDialFragment = SpeedDialFragment().apply { arguments = Bundle() } } }
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: