Skip to main content

Handling Visibility

Litho provides predefined APIs to support a range of use cases where components require callbacks when the on-screen position relative to the visible viewport changes.

Types of Visibility callbacks​

The framework currently supports six types of Visibility callbacks:

  • Visible - invoked when at least 1 pixel of the component is visible. If the component mounts some content, then the event.content will be a reference to that content.
  • Invisible - invoked when the component no longer has any pixels on the screen.
  • Focused - invoked when either the component occupies at least 50% of the viewport or if the component is smaller than half the viewport, when it is fully visible.
  • Unfocused - invoked when the component is no longer focused, such as if it's not fully visible and does not occupy at least half the viewport.
  • Full Impression - if the component is smaller than the viewport, this callback is invoked when the entire component is visible in the viewport. If the Component is bigger than the viewport, then just covering the viewport won't invoke this callback: it will be invoked when all the edges have passed through the viewport once.
  • Visibility Changed - invoked when the visible bounds of the component change. The event object states the visible top and left coordinate, and the current visible width and height of the content.

Usage​

The following example illustrates setting visibility callbacks on a component:

class VisibilityHandlingExampleComponent : KComponent() {

override fun ComponentScope.render(): Component {
return Column(
style =
Style.onVisible { event ->
// If the handler was set on a component which mounts content then the
// event.content will be a reference to the mounted content.
if (event.content is View) {
log("Visible", "View")
} else {
log("Visible", "Drawable")
}
}
.onInvisible { log("Invisible", "null") }) {
child(Text("hello world"))
child(
Row(
style =
Style.onVisibilityChanged { event ->
if (event.percentVisibleHeight > 50) {
Log.d(
"visibility-changed",
"View is mostly visible now. With: " +
"\ntop: ${event.visibleTop}" +
"\nleft: ${event.visibleLeft}" +
"\nvisible width: ${event.visibleWidth}" +
"\nvisible height: ${event.visibleHeight}" +
"\npercentage visible height: ${event.percentVisibleHeight}" +
"\npercentage visible width: ${event.percentVisibleWidth}")
}
}) {
child(Text("This is an example."))
})
}
}

fun log(type: String, content: String) {
Log.d("visibility", "Visibility callback: $type content: $content")
}
}
tip

'Visibility Changed' callback should be used with particular care since it's invoked on every frame while scrolling β€” no heavy work should be done inside. 'Visible', 'Invisible', 'Focused', 'Unfocused', and 'Full Impression' are recommended over 'Visibility Changed' whenever possible.

Custom visibility percentage​

By default, Visible event is triggered when at least 1 pixel of the component is visible. In some cases, it may be needed to set custom visibility threshold. A ratio of the component's visible width or height that will trigger the 'Visible' event can be set using visibleWidthRatio and visibleHeightRatio props.

override fun ComponentScope.render(): Component {
return Column(alignItems = YogaAlign.STRETCH) {
child(
Text(
text = "Do you see me?",
style =
Style.onVisible { logEvent(it) }
.visibleWidthRatio(0.1f)
.visibleHeightRatio(0.8f)))
}
}

For the example above, a 'Visible' event is dispatched when at least 80% of the component's height and 10% of the component's width is visible. When the component's visible percentage changes to less than 80% of total height or to less than 10% of total width, an 'Invisible' event will be dispatched. If not specified, the default width and height ratio is 1f.

Changing LithoView visibility​

There are cases when the visibility callback needs to be invoked on the LithoView components because the LithoView's visibility changed but did not receive any callbacks to inform it of this change. For example, when an activity is added to the back stack, covering the current UI. For such cases, Litho provides the LithoVisibilityEventsController API to notify LithoView about changes in its visibility, and to dispatch correct events to components inside.

LithoVisibilityEventsController API​

The LithoVisibilityEventsController API can be used to inform LithoView about changes in its visibility state.

The LithoVisibilityEventsController.moveToLifecycle() method should be called from the Fragment.setUserVisibleHint() or onResume()/onPause() methods of Activity or Fragment.

interface LithoVisibilityEventsController {

// Should be called to inform Litho that its visibility state has changed
fun moveToLifecycle(lithoLifecycle: LithoLifecycle)

}

Valid LithoVisibilityEventsController states​

  • HINT_INVISIBLE - indicates that the lifecycle provider is considered to be not visible on screen.
    • Lifecycle observers can perform operations that are associated with invisibility status.
    • An example of moving to the HINT_INVISIBLE state is when a fragment goes from Resumed to Paused because the app was backgrounded.
    • Invisible events will be dispatched to all components inside the LithoView that were visible.
  • HINT_VISIBLE - indicates that the lifecycle provider is considered visible on screen.
    • Lifecycle observers can perform operations that are associated with visibility status.
    • An example of moving to the HINT_VISIBLE state is when a fragment goes from Paused to Resumed because the app was foregrounded.
    • Visible events will be dispatched to all components inside the LithoView which meet the visibility criteria.
  • DESTROYED - the final state of a lifecycle provider.
    • Lifecycle observers can perform operations associated with releasing resources.
    • An example of moving to the DESTROYED state is when the hosting Activity is destroyed. The ComponentTree associated with the LithoView will be released.
    • Invisible events will be dispatched to all components that were visible, and all content will be unmounted.

Listening to a LithoVisibilityEventsController state changes​

A LithoView can be registered to listen to state changes of a LithoVisibilityEventsController instance when created:

val lithoView = LithoView.create(c, component, LithoVisibilityEventsController)

Android AOSP implementation​

This is an implementation of LithoVisibilityEventsController which has the state tied to that of an AOSP LifecycleOwner.

  • LifecycleOwner in ON_PAUSE state moves the AOSPLithoVisibilityEventsController to HINT_INVISIBLE state
  • LifecycleOwner in ON_RESUME state moves the AOSPLithoVisibilityEventsController to HINT_VISIBLE state
  • LifecycleOwner in ON_DESTROY state moves the AOSPLithoVisibilityEventsController to DESTROYED state

Use AOSPLithoVisibilityEventsController to associate a LithoView's visibility status with the lifecycle of a Fragment, Activity or custom LifecycleOwner, where Resumed means the LithoView is on screen and Paused means the LithoView is hidden.

val lifecycleProvider = AOSPLithoVisibilityEventsController(this)
val componentContext = ComponentContext(this)
lithoView =
LithoView.create(
this,
LifecycleDelegateComponent.create(componentContext)
.id(atomicId.getAndIncrement().toString())
.delegateListener(delegateListener)
.consoleDelegateListener(consoleDelegateListener)
.build(),
lifecycleProvider /* The LithoVisibilityEventsController for this LithoView */)

Handling custom state changes​

AOSPLithoVisibilityEventsController covers most of the common cases, but there are scenarios where a LifecycleOwner's state doesn't match what we see on screen, as shown in the following examples:

  • Fragments in a ViewPager, where Fragments for the previous and next pages are prepared and in a Resumed state before they're actually visible.

  • Adding a Fragment on top of another Fragment doesn't move the first Fragment to a Paused state, and there's no indication that it's no longer visible to the user.

When state changes need to be handled manually, use AOSPLithoVisibilityEventsController.moveToLifecycle(LithoLifecycle) to change state when appropriate.

The following examples uses LithoVisibilityEventsControllerDelegate, a generic LithoVisibilityEventsController implementation, to change state, but the idea also works for AOSPLithoVisibilityEventsController.

ViewPager example​

private val delegate = LithoVisibilityEventsControllerDelegate()

override fun setUserVisibleHint(isVisibleToUser: Boolean) {
super.setUserVisibleHint(isVisibleToUser)
if (wasVisible == isVisibleToUser) {
return
}
if (isVisibleToUser) {
wasVisible = true
delegate.moveToVisibilityState(
LithoVisibilityEventsController.LithoVisibilityState.HINT_VISIBLE)
} else {
wasVisible = false
delegate.moveToVisibilityState(
LithoVisibilityEventsController.LithoVisibilityState.HINT_INVISIBLE)
}
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val parent = inflater.inflate(R.layout.screen_slide_fragment, container, false) as ViewGroup
val c = ComponentContext(requireContext())
lithoView =
LithoView.create(
c,
getComponent(c),
delegate /* The LithoVisibilityEventsController delegate for this LithoView */)

Fragment Transaction example​

private val delegate: LithoVisibilityEventsControllerDelegate =
LithoVisibilityEventsControllerDelegate()

override fun onClick(view: View) {

// Replaces the current fragment with a new fragment
replaceFragment()

// inform the LithoView
delegate.moveToVisibilityState(
LithoVisibilityEventsController.LithoVisibilityState.HINT_VISIBLE)
}

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val parent =
inflater.inflate(R.layout.activity_fragment_transactions_lifecycle, container, false)
as ViewGroup
val c = ComponentContext(requireContext())
lithoView =
LithoView.create(
c,
getComponent(c),
delegate /* The LithoVisibilityEventsController delegate for this LithoView */)

Nested Component Trees​

The Litho components for building Lists (LazyCollections, Sections, VerticalScrollSpec, HorizontalScrollSpec) create hierarchies of nested ComponentTrees:

  • A ComponentTree at the root of the hierarchy, encapsulating the entire list (associated with a root LithoView)
  • A ComponentTree for each item in the List (associated with a LithoView child of the root LithoView)

If the root LithoView is subscribed to listen to a LithoVisibilityEventsController, then all nested Component Trees / child LithoViews will listen to the outer LithoVisibilityEventsController too and will receive the correct information about visibility/destroyed state.

info

The section below contains information about deprecated APIs. Please consider using LithoVisibilityEventsController for manually informing a LithoView about visibility changes.

(Deprecated) setVisibilityHint​

After calling LithoView.setVisibilityHint(false), the LithoView will consider itself not visible and will ignore any requests to mount until setVisibilityHint(true) is called. The entire LithoView content may be unmounted by calling unmountAll if the visibility hint was set to false.

Resetting the visibility hint to true after it was set to false will also trigger a mount pass, in case the visible bounds changed while the LithoView was ignoring mount requests.

Example usage:

// To dispatch visible/focused events as necessary on all components inside this LithoView
lithoView.setVisibilityHint(true)

// To dispatch invisible/unfocused events as necessary on all components inside this LithoView
lithoView.setVisibilityHint(false)