Reveal: A Jetpack Compose Library for Engaging Reveal Effects
Libraries|Apr 18, 2023|Last edited: Apr 18, 2023
Tags:
Library
Jetpack Compose UI
User Experience (UX)

type
Post
status
Published
date
Apr 18, 2023
slug
interactive-onboarding-reveal-library-jetpack-compose
summary
Explore the Reveal library for Jetpack Compose with this demo app. Add reveal effects to your UI elements and enhance user onboarding. Try it now
tags
Library
Jetpack Compose UI
User Experience (UX)
category
Libraries
icon
password
Introducing the Reveal Library for Jetpack Compose: Create Stunning Onboarding Experiences
Welcome to this blog post, where we will explore the Reveal library for Jetpack Compose. This library provides an exciting way to create reveal effects, commonly known as coach marks or onboarding experiences, for your Android apps. With Reveal, you can guide your users through your app's interface and highlight the essential features by using reveal effects on UI elements. Let's dive into a demo app that showcases the Reveal library and how to use it in your Jetpack Compose apps.
Installation
Please check the GitHub README of the project for complete installation instructions and the demo app for usage of the library.
Check in the Releases section for the version on GitHub.
dependencies { implementation("com.svenjacobs.reveal:reveal-core:$REVEAL_VERSION") }
Demo App: Reveal Library in Action
The demo app features a main screen with a floating action button (FAB). When users tap the FAB, it triggers a reveal effect that displays an explanation of the app's purpose. This interactive onboarding experience helps users understand how to use the app and engage with its features.
Demo App Code: https://github.com/svenjacobs/reveal/tree/main/demo-android
Key Components: RevealOverlayEffect and Reveal Classes
The Reveal library's core components are the
RevealOverlayEffect
and Reveal
classes. Let's take a closer look at each of them.RevealOverlayEffect
The
RevealOverlayEffect
interface defines the overlay effect, which is responsible for rendering the background and reveal effect. Within the Reveal library, you can find an implementation of this interface called DimRevealOverlayEffect
. This class dims the background according to the color parameter specified by the developer.Reveal
The
Reveal
class is a container for elements that you want to apply the reveal effect to. It takes in a revealState
that controls the visibility of the elements, an onRevealableClick
function that is called when a revealable element is clicked, an onOverlayClick
function that is called when the overlay is clicked, and an overlayContent
function responsible for rendering the overlay content.Additional Components: Balloon and Arrow Classes
The demo app also demonstrates the use of the
Balloon
and Arrow
classes from the Reveal library to display the overlay content.Balloon
The
Balloon
class is a customizable speech bubble that you can use to display overlay content. You can style and position the Balloon to suit your app's design and user experience requirements.Arrow
The
Arrow
class defines the direction of the arrow pointing to the UI element, such as the FAB in the demo app. You can use the Arrow class to ensure that the arrow is pointing towards the UI element you want to highlight in the onboarding experience.Conclusion
The Reveal library for Jetpack Compose offers a powerful way to create engaging onboarding experiences and guide users through your app's interface. With its simple yet customizable classes, you can quickly implement reveal effects and highlight essential features, ultimately enhancing user engagement and retention.
Reference
Library and Demo App:
reveal
svenjacobs • Updated May 26, 2023
Code tree which was used for source code explanation section: https://github.com/svenjacobs/reveal/tree/86afc0bfdce66c820b0e510d22415e12f458c728
Source Code Explanation: Exploring Interesting Code from the Reveal Library
@Composable public fun Reveal( onRevealableClick: (key: Key) -> Unit, onOverlayClick: (key: Key) -> Unit, modifier: Modifier = Modifier, revealState: RevealState = rememberRevealState(), overlayEffect: RevealOverlayEffect = DimRevealOverlayEffect(), overlayEffectAnimationSpec: AnimationSpec<Float> = tween(durationMillis = 500), overlayContent: @Composable RevealOverlayScope.(key: Key) -> Unit = {}, content: @Composable RevealScope.() -> Unit, ) { val animatedOverlayAlpha by animateFloatAsState( targetValue = if (revealState.isVisible) 1.0f else 0.0f, animationSpec = overlayEffectAnimationSpec, finishedListener = { alpha -> if (alpha == 0.0f) { revealState.onHideAnimationFinished() } }, ) val layoutDirection = LocalLayoutDirection.current val density = LocalDensity.current Box( modifier = modifier, ) { content(RevealScopeInstance(revealState)) val currentRevealable = remember { derivedStateOf { revealState.currentRevealable?.toActual( density = density, layoutDirection = layoutDirection, ) } } val previousRevealable = remember { derivedStateOf { revealState.previousRevealable?.toActual( density = density, layoutDirection = layoutDirection, ) } } val rev by rememberUpdatedState(currentRevealable.value) val clickModifier = when { revealState.isVisible && rev != null -> Modifier.pointerInput(Unit) { detectTapGestures( onPress = { offset -> rev?.key?.let( if (rev?.area?.contains(offset) == true) { onRevealableClick } else { onOverlayClick }, ) }, ) } else -> Modifier } if (animatedOverlayAlpha > 0.0f) { Fullscreen { overlayEffect.Overlay( revealState = revealState, currentRevealable = currentRevealable, previousRevealable = previousRevealable, modifier = clickModifier .semantics { testTag = "overlay" } .fillMaxSize() .alpha(animatedOverlayAlpha), content = overlayContent, ) } } } } private fun Revealable.toActual( density: Density, layoutDirection: LayoutDirection, ): ActualRevealable = ActualRevealable( key = key, shape = shape, padding = padding, area = computeArea( density = density, layoutDirection = layoutDirection, ), )
This is the implementation of the
Reveal
composable function in the com.svenjacobs.reveal
package.It is a container composable for the reveal effect. It applies an
overlayEffect
and only reveals the current revealable element when it is active. The effect is always rendered fullscreen, and the elements inside the contents of this composable are registered as "revealables" via the RevealScope.revealable
modifier in the scope of the content
composable.The effect is controlled via
RevealState.reveal
and RevealState.hide
. Optionally, an overlayContent
can be specified to place explanatory elements (like texts or images) next to the reveal area. This content is placed above the greyed out backdrop. Elements in this scope can be aligned relative to the reveal area via RevealOverlayScope.align
.The
onRevealableClick
and onOverlayClick
parameters are callbacks that are called when the revealable area or the overlay is clicked. The modifier
parameter is applied to this composable. The revealState
parameter is a state that controls the visibility of the reveal effect. The overlayEffect
parameter is the effect that is used for the background and reveal of items. The overlayEffectAnimationSpec
parameter is the animation spec for the animated alpha value of the overlay effect when showing or hiding.This implementation makes use of several Jetpack Compose functions, including
animateFloatAsState
, remember
, rememberUpdatedState
, derivedStateOf
, Box
, fullscreen
, pointerInput
, detectTapGestures
, Modifier
, semantics
, and fillMaxSize
.@Stable @Suppress("MemberVisibilityCanBePrivate") public class RevealState internal constructor( visible: Boolean = false, private val restoreCurrentRevealableKey: Key? = null, ) { private val mutex = Mutex() private var didRestoreCurrentRevealable = false private var visible by mutableStateOf(visible) private val revealables = mutableStateMapOf<Key, Revealable>() internal var currentRevealable by mutableStateOf<Revealable?>(null) private set internal var previousRevealable by mutableStateOf<Revealable?>(null) private set public val isVisible: Boolean get() = visible public val currentRevealableKey: Key? get() = currentRevealable?.key public val previousRevealableKey: Key? get() = previousRevealable?.key public val revealableKeys: Set<Key> get() = revealables.keys public suspend fun reveal(key: Key) { require(revealables.containsKey(key)) { "Revealable with key \\"$key\\" not found" } mutex.withLock { previousRevealable = currentRevealable currentRevealable = revealables[key] visible = true } } public suspend fun hide() { mutex.withLock { visible = false } } public fun containsRevealable(key: Key): Boolean = revealableKeys.contains(key) internal fun onHideAnimationFinished() { currentRevealable = null previousRevealable = null } public fun putRevealable(revealable: Revealable) { revealables[revealable.key] = revealable if (!didRestoreCurrentRevealable && restoreCurrentRevealableKey == revealable.key) { currentRevealable = revealable didRestoreCurrentRevealable = true } } public fun removeRevealable(key: Key) { revealables.remove(key) if (currentRevealableKey == key) { visible = false } if (previousRevealableKey == key) { previousRevealable = null } } internal companion object { save = { listOf( it.isVisible, it.currentRevealableKey?.let { key -> with(keySaver) { save(key) } }, ) }, restore = { RevealState( visible = it[0] as Boolean, restoreCurrentRevealableKey = it[1]?.let { keySaveable -> keySaver.restore(keySaveable) }, ) }, ) } } @Composable public fun rememberRevealState(keySaver: Saver<Key, Any> = autoSaver()): RevealState = rememberSaveable(saver = RevealState.newSaver(keySaver)) { RevealState() }
This file defines the
RevealState
class and the rememberRevealState
function.The
RevealState
class represents the state of the reveal effect. It stores the currently visible Revealable
, the previous one (if any), and a map of all Revealable
objects registered with the RevealScope
modifier. It also provides methods for showing and hiding the effect, as well as adding and removing Revealable
objects.The
rememberRevealState
function creates and remembers an instance of RevealState
across recompositions. It takes an optional keySaver
parameter which is a custom saver for the Key
type used to identify Revealable
objects.@Immutable public data class Revealable( val key: Key, val shape: RevealShape, val padding: PaddingValues, val layout: Layout, ) { @Immutable public data class Layout( val offset: Offset, val size: Size, ) } @Immutable public data class ActualRevealable( val key: Key, val shape: RevealShape, val padding: PaddingValues, val area: Rect, ) internal fun Revealable.computeArea(density: Density, layoutDirection: LayoutDirection): Rect = with(density) { val rect = Rect( left = layout.offset.x - padding.calculateLeftPadding(layoutDirection).toPx(), top = layout.offset.y - padding.calculateTopPadding().toPx(), right = layout.offset.x + padding.calculateRightPadding(layoutDirection).toPx() + layout.size.width, bottom = layout.offset.y + padding.calculateBottomPadding().toPx() + layout.size.height, ) if (shape == RevealShape.Circle) { Rect(rect.center, rect.maxDimension / 2.0f) } else { rect } }
These classes and extension function are part of the implementation of the Reveal effect in Jetpack Compose.
Revealable
is a data class that represents a component that can be revealed. It contains a unique key
, a RevealShape
to define the shape of the reveal area, a PaddingValues
to define the padding around the component, and a Layout
to define the position and size of the component.ActualRevealable
is a data class that represents a component that has already been revealed. It has the same properties as Revealable
, but its Layout
is replaced with an area
of type Rect
that represents the actual reveal area in pixels.The extension function
computeArea
is used to calculate the Rect
in pixels of the reveal area including padding for a Revealable
. It takes a Density
and a LayoutDirection
parameter to convert the padding to pixels and handle the case when the RevealShape
is Circle
.These classes and function are used internally to implement the Reveal effect in Jetpack Compose and are not meant to be used directly by users.
@Immutable public interface RevealScope { public fun Modifier.revealable( key: Key, shape: RevealShape = RevealShape.RoundRect(4.dp), padding: PaddingValues = PaddingValues(8.dp), ): Modifier public fun Modifier.revealable( vararg keys: Key, shape: RevealShape = RevealShape.RoundRect(4.dp), padding: PaddingValues = PaddingValues(8.dp), ): Modifier public fun Modifier.revealable( keys: Iterable<Key>, shape: RevealShape = RevealShape.RoundRect(4.dp), padding: PaddingValues = PaddingValues(8.dp), ): Modifier } internal class RevealScopeInstance( private val revealState: RevealState, ) : RevealScope { override fun Modifier.revealable(key: Key, shape: RevealShape, padding: PaddingValues): Modifier = revealable( keys = listOf(key), shape = shape, padding = padding, ) override fun Modifier.revealable( vararg keys: Key, shape: RevealShape, padding: PaddingValues, ): Modifier = revealable( keys = keys.toList(), shape = shape, padding = padding, ) override fun Modifier.revealable( keys: Iterable<Key>, shape: RevealShape, padding: PaddingValues, ): Modifier = this.then( Modifier .onGloballyPositioned { layoutCoordinates -> for (key in keys) { revealState.putRevealable( Revealable( key = key, shape = shape, padding = padding, layout = Revealable.Layout( offset = layoutCoordinates.positionInRoot(), size = layoutCoordinates.size.toSize(), ), ), ) } } .composed { DisposableEffect(Unit) { onDispose { for (key in keys) { revealState.removeRevealable(key) } } } this }, ) }
This is the implementation of the
RevealScope
interface which is used as a scope for the Reveal
composable. The RevealScope
provides a revealable
modifier which is used to register elements as revealable items.The
revealable
modifier takes a Key
to identify the revealable content, a RevealShape
to specify the shape of the reveal effect around the element, and a PaddingValues
to specify additional padding around the reveal area.The
onGloballyPositioned
modifier is used to register the element as a revealable item after it has been laid out. The DisposableEffect
is used to remove the revealable item when the element leaves the composition.The
RevealScopeInstance
class is the implementation of the RevealScope
interface. It takes a RevealState
instance in the constructor and registers revealable items with the putRevealable
method of the RevealState
. The then
function is used to compose the onGloballyPositioned
and DisposableEffect
modifiers with the existing modifier.public typealias Key = Any
This is a typealias declaration in the
com.svenjacobs.reveal
package that aliases the type Any
to Key
. It is used to identify revealable content and must be unique per RevealState
instance.public interface RevealOverlayEffect { @Composable public fun Overlay( revealState: RevealState, currentRevealable: State<ActualRevealable?>, previousRevealable: State<ActualRevealable?>, modifier: Modifier, content: @Composable RevealOverlayScope.(key: Key) -> Unit, ) }
The
RevealOverlayEffect
is an interface that provides a composable function called Overlay
. This function takes in several parameters:The purpose of this interface is to provide a way to render the background and reveal effect for the currently revealed item. By using an effect, the implementation of the overlay can be abstracted away and made interchangeable. Different implementations of this effect can be used to provide different types of reveal effects or animations.
Currently only
DimRevealOverlayEffect
is provided, which provides a simple dimming effect. The implementation of this effect is shown below.@Immutable public class DimRevealOverlayEffect( private val color: Color = Color.Black.copy(alpha = 0.8f), private val contentAnimationSpec: AnimationSpec<Float> = tween(durationMillis = 500), ) : RevealOverlayEffect { @Composable override fun Overlay( revealState: RevealState, currentRevealable: State<ActualRevealable?>, previousRevealable: State<ActualRevealable?>, modifier: Modifier, content: @Composable RevealOverlayScope.(key: Key) -> Unit, ) { val currentItemHolder = currentRevealable.value?.let { rememberDimItemHolder( revealable = it, fromState = Gone, toState = Visible, contentAnimationSpec = contentAnimationSpec, ) } val prevItemHolder = previousRevealable.value?.let { rememberDimItemHolder( revealable = it, fromState = Visible, toState = Gone, contentAnimationSpec = contentAnimationSpec, ) } val density = LocalDensity.current Box( modifier = modifier .graphicsLayer(alpha = 0.99f) .drawBehind { drawRect(color) prevItemHolder?.let { with(it) { drawCutout(density) } } currentItemHolder?.let { with(it) { drawCutout(density) } } }, ) { prevItemHolder?.let { with(it) { Container(content = content) } } currentItemHolder?.let { with(it) { Container(content = content) } } } } } @Stable private class DimItemHolder( val revealable: ActualRevealable, val contentAlpha: State<Float>, ) { @Composable fun BoxScope.Container( modifier: Modifier = Modifier, content: @Composable RevealOverlayScope.(key: Key) -> Unit, ) { if (contentAlpha.value == 0.0f) return Box( modifier = modifier .matchParentSize() .alpha(contentAlpha.value), content = { RevealOverlayScopeInstance( revealableRect = revealable.area.toIntRect(), ).content(revealable.key) }, ) } fun DrawScope.drawCutout(density: Density) { val path = revealable.createShapePath( density = density, layoutDirection = layoutDirection, ) drawPath( path, Color.Transparent.copy(alpha = 1.0f - contentAlpha.value), blendMode = BlendMode.DstIn, ) } } private enum class DimItemState { Visible, Gone } @Composable private fun rememberDimItemHolder( revealable: ActualRevealable, fromState: DimItemState, toState: DimItemState, contentAnimationSpec: AnimationSpec<Float>, ): DimItemHolder = key(revealable.key) { val targetState = remember { mutableStateOf(fromState) } val contentAlpha = animateFloatAsState( targetValue = if (targetState.value == Visible) 1.0f else 0.0f, animationSpec = contentAnimationSpec, ) LaunchedEffect(Unit) { targetState.value = toState } remember { DimItemHolder( revealable = revealable, contentAlpha = contentAlpha, ) } }
This is a Kotlin file that defines the
DimRevealOverlayEffect
class that implements the RevealOverlayEffect
interface. This effect dims the background with a specified color and an animation when a revealable item is revealed.The
Overlay
function is the main function that takes the RevealState
, currentRevealable
, previousRevealable
, modifier
, and content
as input parameters. The modifier
parameter is used to add extra effects to the Box
element that renders the reveal overlay.Inside the
Overlay
function, the current revealable and previous revealable are passed as inputs to the rememberDimItemHolder
function. This function returns a DimItemHolder
instance that holds information about the revealable and its state, as well as its content alpha.Finally, the
rememberDimItemHolder
function is used inside the Box
element to render the content
with the alpha value animated based on the fromState
, toState
, and contentAnimationSpec
parameters of the rememberDimItemHolder
function. The drawBehind
function is used to draw the dimmed background color and cut out the revealable area with a transparent color.Source Code Explanation: Exploring Code from the Demo App
private enum class Keys { Fab, Explanation } @Composable @OptIn(ExperimentalMaterial3Api::class) fun MainScreen(modifier: Modifier = Modifier) { val revealState = rememberRevealState() LaunchedEffect(Unit) { if (revealState.isVisible) return@LaunchedEffect delay(2.seconds) revealState.reveal(Keys.Fab) } DemoTheme { val scope = rememberCoroutineScope() Reveal( modifier = modifier, revealState = revealState, onRevealableClick = { key -> scope.launch { if (key == Keys.Fab) { revealState.reveal(Keys.Explanation) } else { revealState.hide() } } }, onOverlayClick = { scope.launch { revealState.hide() } }, overlayContent = { key -> RevealOverlayContent(key) }, ) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { CenterAlignedTopAppBar( title = { Text("Reveal Demo") }, ) }, floatingActionButton = { FloatingActionButton( modifier = Modifier.revealable( key = Keys.Fab, shape = RevealShape.RoundRect(16.dp), ), onClick = { scope.launch { revealState.reveal(Keys.Explanation) } }, ) { Icon( Icons.Filled.Add, contentDescription = null, ) } }, ) { contentPadding -> Column( modifier = Modifier .fillMaxSize() .padding(contentPadding) .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Text( modifier = Modifier .padding(top = 16.dp) .revealable( key = Keys.Explanation, ), text = "Reveal is a lightweight, simple reveal effect (also known as " + "coach mark or onboarding) library for Jetpack Compose.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Justify, ) } } } } } @Composable private fun RevealOverlayScope.RevealOverlayContent(key: Key) { when (key) { Keys.Fab -> OverlayText( modifier = Modifier.align( horizontalArrangement = RevealOverlayArrangement.Start, ), text = "Click button to get started", arrow = Arrow.end(), ) Keys.Explanation -> OverlayText( modifier = Modifier.align( verticalArrangement = RevealOverlayArrangement.Bottom, ), text = "Actually we already started. This was an example of the reveal effect.", arrow = Arrow.top(), ) } } @Composable private fun OverlayText(text: String, arrow: Arrow, modifier: Modifier = Modifier) { Balloon( modifier = modifier.padding(8.dp), arrow = arrow, backgroundColor = MaterialTheme.colorScheme.secondaryContainer, elevation = 2.dp, ) { Text( modifier = Modifier.padding(8.dp), text = text, style = MaterialTheme.typography.labelLarge, textAlign = TextAlign.Center, ) } } @Composable @Preview(showBackground = true) private fun MainScreenPreview() { DemoTheme { MainScreen() } }
This is a Jetpack Compose UI for a demo app that showcases a lightweight, simple reveal effect library called "Reveal."
The demo app consists of a main screen that has a centered title, a floating action button (FAB) with an icon, and a column of text. The FAB is the trigger for the reveal effect. When it's clicked, an overlay appears on the screen with a message and an arrow pointing at the FAB.
The overlay is the "reveal effect."