Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.swmansion.rnscreens.core.containers.common

import android.os.Handler
import android.os.Looper

/**
* Decides when a container runs its update, so that any number of update requests results in
* a single run of [updateCallback].
*
* - [requestUpdate] posts one run to the main thread's message queue. Further requests do nothing
* until that run happens.
* - Between [beginBatch] and [endBatch] (e.g. a React transaction) requests are only recorded,
* and [endBatch] runs the update once if any was requested. Batches don't nest.
* - [flush] runs the update right away, inside a batch too, and drops the posted run.
*
* The scheduler only decides when; [updateCallback] decides what, from the container's own
* invalidation state, and must do nothing when nothing is invalidated: [flush] runs it whether or
* not an update was requested. A request raised while [updateCallback] runs schedules another run.
*
* Main thread only.
*/
internal class ContainerUpdateScheduler(
private val updateCallback: () -> Unit,
) {
private val handler = Handler(Looper.getMainLooper())

// A run posted before a batch began waits for the batch to end.
private val postedUpdate =
Runnable {
if (!isInBatch) {
flush()
}
}

private var isUpdateRequested = false

private var isInBatch = false

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the batches do not nest it would be beneficial to make this internal (for read) with private setter, so that a caller can detect whether the scheduler is already batching or not.


fun requestUpdate() {
if (isUpdateRequested) {
return
}
isUpdateRequested = true
if (!isInBatch) {
handler.post(postedUpdate)
}
}

fun flush() {
cancel()
updateCallback()
}

/**
* Drops the requested update without running it.
*/
fun cancel() {
isUpdateRequested = false
handler.removeCallbacks(postedUpdate)
}

fun beginBatch() {
isInBatch = true
}

fun endBatch() {
isInBatch = false
if (isUpdateRequested) {
flush()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import com.swmansion.rnscreens.core.common.safearea.SafeAreaProvider
import com.swmansion.rnscreens.core.common.text.FontResolver
import com.swmansion.rnscreens.core.containers.common.Container
import com.swmansion.rnscreens.core.containers.common.ContainerItem
import com.swmansion.rnscreens.core.containers.common.ContainerUpdateScheduler
import com.swmansion.rnscreens.core.containers.common.ParentContainerItemRegistry
import com.swmansion.rnscreens.core.containers.tabs.appearance.TabsAppearance
import com.swmansion.rnscreens.core.containers.tabs.appearance.TabsAppearanceCoordinator
Expand Down Expand Up @@ -172,9 +173,7 @@ class TabsContainer internal constructor(
if (newValue != oldValue) {
updateInterfaceInsets()
invalidationFlags.isNavigationMenuAppearanceInvalidated = true
post {
flushPendingUpdates()
}
updateScheduler.requestUpdate()
}
}

Expand All @@ -196,8 +195,8 @@ class TabsContainer internal constructor(
get() = navState

/**
* Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the
* host's next render cycle to apply automatically.
* Queue a navigation state update. The container applies it with its next scheduled update;
* call [flushPendingUpdates] to apply it right away.
*/
fun submitSelectionOfTabsScreenWithKey(screenKey: String) {
setPendingNavigationStateUpdate(
Expand All @@ -210,14 +209,10 @@ class TabsContainer internal constructor(
}

/**
* Apply any pending invalidations and state updates in a single coordinated pass.
* No-op when nothing is dirty or the view is detached.
* Apply any pending invalidations and state updates in a single coordinated pass, right away
* instead of with the next scheduled update. No-op when nothing is dirty or the view is detached.
*/
fun flushPendingUpdates() {
if (invalidationFlags.any() && isAttachedToWindow) {
performContainerUpdate()
}
}
fun flushPendingUpdates() = updateScheduler.flush()

fun addNavigationStateObserver(observer: TabsNavigationStateObserver): Boolean = observerRegistry.add(observer)

Expand All @@ -228,12 +223,15 @@ class TabsContainer internal constructor(
// region Host-internal API

/**
* Queue a navigation state update. Apply via [flushPendingUpdates] or rely on the
* host's next render cycle to apply automatically.
* Queue a navigation state update. The container applies it with its next scheduled update;
* call [flushPendingUpdates] to apply it right away.
*/
internal fun setPendingNavigationStateUpdate(request: TabsNavigationStateUpdateRequest?) {
pendingStateUpdateRequest = request
invalidationFlags.isSelectedTabInvalidated = request != null
if (request != null) {
updateScheduler.requestUpdate()
}
}

internal fun addItemAt(
Expand All @@ -243,26 +241,30 @@ class TabsContainer internal constructor(
tabsModel.add(index, TabsScreenFragment(item, viewIdProvider.generateViewId(), WeakReference(this)))
item.setConfigurationObserver { flags -> onItemInvalidated(item, flags) }
invalidationFlags.invalidateAll()
updateScheduler.requestUpdate()
}

internal fun removeItemAt(index: Int): TabsContainerItem? =
tabsModel.removeAt(index).item.also {
it.setConfigurationObserver(null)
invalidationFlags.invalidateAll()
updateScheduler.requestUpdate()
}

internal fun removeItem(item: TabsContainerItem): Boolean =
tabsModel.removeIf { it.item === item }.also { isRemoved ->
if (isRemoved) {
item.setConfigurationObserver(null)
invalidationFlags.invalidateAll()
updateScheduler.requestUpdate()
}
}

internal fun removeAllItems() {
tabsModel.forEach { it.item.setConfigurationObserver(null) }
tabsModel.clear()
invalidationFlags.invalidateAll()
updateScheduler.requestUpdate()
}

internal fun setupFragmentManager() {
Expand All @@ -284,6 +286,7 @@ class TabsContainer internal constructor(
internal fun tearDown() {
observerRegistry.clear()
setPendingNavigationStateUpdate(null)
updateScheduler.cancel()
}

internal fun onAfterSetSelectedItemId(
Expand Down Expand Up @@ -408,6 +411,25 @@ class TabsContainer internal constructor(

// endregion

// region Update scheduling

/**
* Every invalidation requests an update; the scheduler coalesces the requests into a single
* [performContainerUpdateIfNeeded]. Outside a batch (e.g. an icon loaded asynchronously) the
* update is posted to the main thread's message queue.
*/
private val updateScheduler = ContainerUpdateScheduler(::performContainerUpdateIfNeeded)

/**
* Announces that the host is applying a batch of updates (e.g. a React transaction). Updates
* requested until [endUpdateBatch] are applied together, once, when the batch ends.
*/
internal fun beginUpdateBatch() = updateScheduler.beginBatch()

internal fun endUpdateBatch() = updateScheduler.endBatch()

// endregion

// region Tab configuration changes

/**
Expand All @@ -427,21 +449,19 @@ class TabsContainer internal constructor(
// Skip when detached; reattachment reapplies the full appearance anyway.
if (isAttachedToWindow) {
invalidationFlags.isNavigationMenuAppearanceInvalidated = true
post {
this.flushPendingUpdates()
}
}
} else if (flags.containsAny(TabsItemInvalidationFlags.APPEARANCE)) {
if (isAttachedToWindow && selectedTab.item === item) {
invalidationFlags.isNavigationMenuAppearanceInvalidated = true
post {
this.flushPendingUpdates()
}
}
}

if (flags.containsAny(TabsItemInvalidationFlags.MENU_ITEM_ATTRIBUTES)) {
updateMenuItem(item)
invalidationFlags.isMenuItemsInvalidated = true
}

if (invalidationFlags.any()) {
updateScheduler.requestUpdate()
}
}

Expand Down Expand Up @@ -487,6 +507,15 @@ class TabsContainer internal constructor(

// region Private helpers

/**
* The update the [updateScheduler] runs. Skipped while detached: [onAttachedToWindow] flushes.
*/
private fun performContainerUpdateIfNeeded() {
if (invalidationFlags.any() && isAttachedToWindow) {
performContainerUpdate()
}
}

/**
* This is where programmatic update flow starts.
* This includes any JS-triggered action (navigation state update, prop update, etc.)
Expand Down Expand Up @@ -516,6 +545,20 @@ class TabsContainer internal constructor(

private fun performPostSelectedTabUpdateActions() {
updateBottomNavigationViewAppearanceIfNeeded()
updateMenuItemsIfNeeded()
}

/**
* Applies the menu items' pending per-item changes. Runs after the whole-bar appearance update,
* which already consumed the pending flags of every menu item it updated.
*/
private fun updateMenuItemsIfNeeded() {
// Nothing is selected yet; the appearance update that follows the first selection applies them.
if (!invalidationFlags.isMenuItemsInvalidated || navState.isEmpty()) {
return
}
invalidationFlags.isMenuItemsInvalidated = false
tabsModel.forEach { updateMenuItem(it.item) }
}

private fun updateNavigationMenuStructureIfNeeded() {
Expand Down Expand Up @@ -717,6 +760,11 @@ class TabsContainer internal constructor(
// This line assumes that any required e.g. appearance actions will be performed
// synchronously later in the flow.
invalidationFlags.invalidateOnSelectedTabChanged()
if (actionOrigin == TabsActionOrigin.USER) {
// A tap reaches this listener through the menu's item action, not `setSelectedItemId`,
// so [onAfterSetSelectedItemId] doesn't run for it: schedule the update instead.
updateScheduler.requestUpdate()
}
}

if (stateChanged) {
Expand Down Expand Up @@ -883,13 +931,19 @@ internal class TabsContainerInvalidationFlags(
var isSelectedTabInvalidated: Boolean = false,
var isNavigationMenuAppearanceInvalidated: Boolean = false,
var isNavigationMenuStructureInvalidated: Boolean = false,
var isMenuItemsInvalidated: Boolean = false,
) {
internal fun any(): Boolean = isSelectedTabInvalidated || isNavigationMenuAppearanceInvalidated || isNavigationMenuStructureInvalidated
internal fun any(): Boolean =
isSelectedTabInvalidated ||
isNavigationMenuAppearanceInvalidated ||
isNavigationMenuStructureInvalidated ||
isMenuItemsInvalidated

internal fun invalidateAll() {
isSelectedTabInvalidated = true
isNavigationMenuAppearanceInvalidated = true
isNavigationMenuStructureInvalidated = true
isMenuItemsInvalidated = true
}

internal fun invalidateOnSelectedTabChanged() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ class TabsHost(
}

override fun didMountItems(uiManager: UIManager) {
container.flushPendingUpdates()
container.endUpdateBatch()
}

/**
Expand All @@ -258,7 +258,9 @@ class TabsHost(

override fun willDispatchViewUpdates(uiManager: UIManager) = Unit

override fun willMountItems(uiManager: UIManager) = Unit
override fun willMountItems(uiManager: UIManager) {
container.beginUpdateBatch()
}

override fun didDispatchMountItems(uiManager: UIManager) = Unit

Expand Down
Loading