Reveal: A Jetpack Compose Library for Engaging Reveal Effects

Libraries|Apr 18, 2023|Last edited: Apr 18, 2023
  • Library
  • Jetpack Compose UI
  • User Experience (UX)
  • Reveal: A Jetpack Compose Library for Engaging Reveal Effects
    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.
     
     
     

    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

    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 &amp;&amp; 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 &amp;&amp; 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."
    KDoctor: Your Ultimate Troubleshooting Companion for the Kotlin Multiplatform EcosystemClickable Line Numbers in Timber Logs: Boosting Your Android Debugging Experience