Skip to content
Open
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,63 @@
package com.swmansion.rnscreens.gamma.helpers

import android.content.Context
import android.graphics.drawable.Drawable

/**
* Outcome of [IconResolver.resolve].
*
* [Unchanged] is reported when the requested source matches the one the resolver
* last emitted, so the caller should keep whatever icon it already has (no reload
* happens). [Resolved] carries a freshly resolved drawable, or `null` when the
* source resolves to no icon (i.e. the icon should be cleared).
*/
internal sealed interface IconResolution {
object Unchanged : IconResolution

data class Resolved(
val drawable: Drawable?,
) : IconResolution
}

internal class IconResolver {
private var lastDrawableName: String? = null
private var lastImageUri: String? = null
private var lastEmittedDrawableName: String? = null
private var lastEmittedImageUri: String? = null
Comment thread
kligarski marked this conversation as resolved.

/**
* Resolves an icon from a drawable resource name or an image uri.
*
* The result is delivered to [onResult] synchronously for drawable resources and empty sources,
* and asynchronously for image uris. For image uris, the callback is only invoked if the
* resolved uri is still the latest requested source (stale requests are dropped).
*/
Comment thread
kligarski marked this conversation as resolved.
fun resolve(
context: Context,
drawableIconResourceName: String?,
imageIconUri: String?,
onResult: (IconResolution) -> Unit,
) {
lastDrawableName = drawableIconResourceName
lastImageUri = imageIconUri
if (drawableIconResourceName == lastEmittedDrawableName &&
imageIconUri == lastEmittedImageUri
) {
onResult(IconResolution.Unchanged)
return
}
lastEmittedDrawableName = drawableIconResourceName
lastEmittedImageUri = imageIconUri
when {
drawableIconResourceName != null ->
onResult(IconResolution.Resolved(getSystemDrawableResource(context, drawableIconResourceName)))
imageIconUri != null ->
loadImage(context, imageIconUri) { drawable ->
if (imageIconUri == lastImageUri && lastDrawableName == null) {
onResult(IconResolution.Resolved(drawable))
}
}
else -> onResult(IconResolution.Resolved(null))
Comment on lines +43 to +60
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I don't think this is an issue now. Let me know if you think we should add it now.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -607,7 +607,7 @@ internal class StackHeaderCoordinator(
options: StackHeaderToolbarMenuItemOptions,
) {
appBarLayout?.toolbar?.let {
toolbarMenuCoordinator.updateItem(it.menu, id, options)
toolbarMenuCoordinator.updateItem(it, id, options)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ package com.swmansion.rnscreens.gamma.stack.header.config
import android.annotation.SuppressLint
import android.graphics.drawable.Drawable
import android.util.LayoutDirection
import android.util.Log
import com.facebook.react.bridge.ReactContext
import com.facebook.react.views.view.ReactViewGroup
import com.swmansion.rnscreens.gamma.common.ShadowStateProxy
import com.swmansion.rnscreens.gamma.helpers.getSystemDrawableResource
import com.swmansion.rnscreens.gamma.helpers.loadImage
import com.swmansion.rnscreens.gamma.helpers.IconResolution
import com.swmansion.rnscreens.gamma.helpers.IconResolver
import com.swmansion.rnscreens.gamma.stack.header.subview.OnStackHeaderSubviewChangeListener
import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubview
import com.swmansion.rnscreens.gamma.stack.header.subview.StackHeaderSubviewType
import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemConfig
import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemIconSource
import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarMenuItemOptions
import com.swmansion.rnscreens.gamma.stack.header.toolbar.StackHeaderToolbarUpdate
import java.lang.ref.WeakReference

@SuppressLint("ViewConstructor")
Expand Down Expand Up @@ -55,34 +58,80 @@ class StackHeaderConfig(
// Resolution happens in resolveBackButtonIconIfNeeded(), called from onAfterUpdateTransaction.
internal var backButtonDrawableIconResourceName: String? = null
internal var backButtonImageIconUri: String? = null

private var lastResolvedDrawableIconResourceName: String? = null
private var lastResolvedImageIconUri: String? = null
private val backButtonIconResolver = IconResolver()

internal fun resolveBackButtonIconIfNeeded() {
val name = backButtonDrawableIconResourceName
val uri = backButtonImageIconUri

if (name == lastResolvedDrawableIconResourceName && uri == lastResolvedImageIconUri) {
return
}

lastResolvedDrawableIconResourceName = name
lastResolvedImageIconUri = uri

if (name != null) {
backButtonIcon = getSystemDrawableResource(context, name)
} else if (uri != null) {
loadImage(context, uri) { drawable ->
if (uri == lastResolvedImageIconUri) {
backButtonIcon = drawable
// We need to call notifyConfigChanged because icons are loaded asynchronously
// and regular update path might execute too early.
backButtonIconResolver.resolve(
reactContext,
backButtonDrawableIconResourceName,
backButtonImageIconUri,
) { result ->
when (result) {
IconResolution.Unchanged -> Unit
is IconResolution.Resolved -> {
backButtonIcon = result.drawable
notifyConfigChanged()
}
}
} else {
backButtonIcon = null
}
}

internal var toolbarMenuItemIconSourceMap = mapOf<String, StackHeaderToolbarMenuItemIconSource>()

private var toolbarMenuItemIconResolvers = mapOf<String, IconResolver>()

// Last resolved icon per menu item id. Unlike every other field on this
// config — which mirrors a single prop — this cache deliberately merges
// resolved icons from BOTH sources that can set a menu item icon: the
// `toolbarMenuItems` prop array (resolveToolbarMenuItemIconsIfNeeded) and
// the imperative `setToolbarMenuItemOptions` view command
// (dispatchMenuItemUpdate). It is necessary to ensure consistency.
private var toolbarMenuItemIcons = mapOf<String, Drawable?>()

internal fun resolveToolbarMenuItemIconsIfNeeded() {
val nextResolvers = mutableMapOf<String, IconResolver>()

toolbarMenuItemIconSourceMap.forEach { (id, source) ->
val resolver = toolbarMenuItemIconResolvers[id] ?: IconResolver()
nextResolvers[id] = resolver

Comment thread
kligarski marked this conversation as resolved.
resolver.resolve(
context = reactContext,
drawableIconResourceName = source.drawableIconResourceName,
imageIconUri = source.imageIconUri,
) { result ->
val icon =
when (result) {
IconResolution.Unchanged -> toolbarMenuItemIcons[id]
is IconResolution.Resolved -> {
toolbarMenuItemIcons = toolbarMenuItemIcons + (id to result.drawable)
result.drawable
}
}

applyToolbarMenuItemIcon(id, icon)
}
}

toolbarMenuItemIconResolvers = nextResolvers
toolbarMenuItemIcons = toolbarMenuItemIcons.filterKeys { it in toolbarMenuItemIconSourceMap }
}

private fun applyToolbarMenuItemIcon(
id: String,
icon: Drawable?,
) {
val currentItems = toolbarMenuItems
val itemIndex = currentItems.indexOfFirst { it.id == id }
if (itemIndex == -1) return

val item = currentItems[itemIndex]
if (item.icon != icon) {
val newItems = currentItems.toMutableList()
newItems[itemIndex] = item.copy(icon = icon)

toolbarMenuItems = newItems
notifyConfigChanged()
}
}

Expand Down Expand Up @@ -141,11 +190,43 @@ class StackHeaderConfig(
delegate?.get()?.onConfigChange(this)
}

/**
* Applies a toolbar menu item view command. When the command does not touch
* the icon ([iconSource] is `null`) the options are delivered immediately.
* Otherwise, the icon is resolved first and all options — including the icon —
* are delivered together in a single update, so the change is applied
* atomically once the (possibly async) image has loaded.
*/
internal fun dispatchMenuItemUpdate(
id: String,
options: StackHeaderToolbarMenuItemOptions,
iconSource: StackHeaderToolbarMenuItemIconSource?,
) {
delegate?.get()?.onMenuItemUpdate(id, options)
if (iconSource == null) {
delegate?.get()?.onMenuItemUpdate(id, options)
return
}

val resolver = toolbarMenuItemIconResolvers[id]
if (resolver == null) {
Log.w(TAG, "[RNScreens] Unable to find icon resolver for menu item $id.")
delegate?.get()?.onMenuItemUpdate(id, options)
return
}

resolver.resolve(reactContext, iconSource.drawableIconResourceName, iconSource.imageIconUri) { result ->
val icon =
when (result) {
IconResolution.Unchanged -> null // keep the current icon
is IconResolution.Resolved -> {
// Keep the cache in sync with the prop-array path: both share this
// id's resolver.
toolbarMenuItemIcons = toolbarMenuItemIcons + (id to result.drawable)
StackHeaderToolbarUpdate.from(result.drawable)
}
}
delegate?.get()?.onMenuItemUpdate(id, options.copy(icon = icon))
}
}

override fun onStackHeaderSubviewChange() = notifyConfigChanged()
Expand Down Expand Up @@ -189,4 +270,8 @@ class StackHeaderConfig(
// The order of the subviews MUST match the order of JS StackHeaderConfig children.
internal fun getConfigSubviewAt(index: Int): StackHeaderSubview? =
listOfNotNull(backgroundSubview, leadingSubview, centerSubview, trailingSubview).getOrNull(index)

companion object {
private const val TAG = "StackHeaderConfig"
}
}
Loading
Loading