we've encountered a problem with Jetpack Compose button actions leaking. We provide a class with resources (e.g strings, colors, and BUTTON ACTIONS):
data class CameraOnBoardingComposeViewResources(val title: String, val buttonTitle: String, val buttonAction: () -> Unit)
This data class is created in the fragment like so:
val resources = CameraOnBoardingComposeViewResources(
"Title", "ButtonTitle"
) { navigate() }
And in our composable we use those resources like so:
#Composable
fun composeOnBoardingView(resources: CameraOnBoardingComposeViewResources) {
Surface(
shape = RoundedCornerShape(4.dp),
color = colorResource(R.color.idenfyMainColorV2),
modifier = Modifier
.height(42.dp)
.padding(start = 16.dp, end = 16.dp)
.clickable(onClick = resources.buttonAction)
.fillMaxWidth()
)
}
The problem is that buttonAction leaks. How can we avoid this leak, should we somehow dispose of it, or do we need to change this architecture a bit?
EDIT After CommonsWare
Ok, I’ve located the problem, and it is indeed inside the composeOnBoardingView composable. It’s this code snippet:
val currentInstructionDescription: MutableState<String> = remember {
mutableStateOf("")
}
val progress = remember { mutableStateOf(0.0f) }
val handler = Handler(Looper.getMainLooper())
exoPlayer?.apply {
val runnable = object : Runnable {
override fun run() {
progress.value =
exoPlayer.currentPosition.toFloat() / exoPlayer.duration.toFloat()
currentInstructionDescription.value = InstructionsDescriptionBuilder.build(
context,
(resources.documentCenterImageUiViewModel as OnBoardingCenterImageUIViewModel.DocumentResource).step,
exoPlayer.currentPosition.toInt() / 1000
)
handler.postDelayed(this, 1000)
}
}
handler.post(runnable)
}
I tried disposing the handler like this:
val lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
onDispose {
handler.removeCallbacksAndMessages(null)
}
}
But that did not solve the problem. Any ideas?
EDIT
The issue is solved thanks CommonsWare! We switched to the LaunchedEffect and coroutines for an every-second in-composition timer, rather than Handler
Related
I'm new to jetpack compose and I have created a composable function with a simple text.
I would like to update it every time a timer reaches timer.ontick function
but it does not work. Any help?
fun LKIDView(text : String, onLKIDViewChange: (String) -> Unit) {
var lkidState by remember { mutableStateOf("Default") }
val onlkidChange={text : String -> lkidState = text}
Column(
horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF7DCEA0))
) {
Text(
text = lkidState,
// modifier = Modifier.fillMaxWidth(),
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.SansSerif,
//
)
}
}`
`
My ontimer.tick looks like this
`val timer = object : CountDownTimer(Gtime, Ttime) {
var lkidviewState = remember { mutableStateOf("Landkreis/Kreeisfreie Stadt") }
val onTextChange={text : String -> lkidviewState.value = text}
override fun onTick(millisUntilFinished: Long) {
Log.e("TimerTick - ", "Tick ")
LKIDView(text =lkidviewState.value , onLKIDViewChange = onTextChange)
// lkidviewState.value = "dsfdsfdsf"`}`
Android Studio says composable invocation can only happen from the context of a composable function
timer runs - the code did not update the ui
Compose doesn't work in this way.
You can't call a composable inside the CountDownTimer to display the updated value.
Instead you have to use a state (lkidState), and then CountDownTimer has to update this value.
val lkidState = remember {
mutableStateOf(0)
}
val timer = object : CountDownTimer(0, 1000) {
override fun onTick(millisUntilFinished: Long) {
lkidState.value = millisUntilFinished
}
//...
}
Text(text = lkidState.value.toString())
Final note, I would use other option instead of a CountDownTimer, like a side effect.
In order to share settings among of compose functions, I create a class AboutState() and a compose fun rememberAboutState() to persist settings.
I don't know if I can wrap Modifier with remember in the solution.
The Code A can work well, but I don't know if it maybe cause problem when I wrap Modifier with remember, I think Modifier is special class and it's polymorphic based invoked.
Code A
#Composable
fun ScreenAbout(
aboutState: AboutState = rememberAboutState()
) {
Column() {
Hello(aboutState)
World(aboutState)
}
}
#Composable
fun Hello(
aboutState: AboutState
) {
Text("Hello",aboutState.modifier)
}
#Composable
fun World(
aboutState: AboutState
) {
Text("World",aboutState.modifier)
}
class AboutState(
val textStyle: TextStyle,
val modifier: Modifier=Modifier
) {
val rowSpace: Dp = 20.dp
}
#Composable
fun rememberAboutState(): AboutState {
val aboutState = AboutState(
textStyle = MaterialTheme.typography.body1.copy(
color=Color.Red
),
modifier=Modifier.padding(start = 80.dp)
)
return remember {
aboutState
}
}
There wouldn't be a problem passing a Modifier to a class. What you actually defined above, even if named State, is not class that acts as a State, it would me more appropriate name it as HelloStyle, HelloDefaults.style(), etc.
It would be more appropriate to name a class XState when it should have internal or public MutableState that can trigger recomposition or you can get current State of Composable or Modifier due to changes. It shouldn't contain only styling but state mechanism either to change or observe state of the Composble such as ScrollState or PagerState.
When you have a State wrapper object common way of having a stateful Modifier or Modifier with memory or Modifiers with Compose scope is using Modifier.composed{} and passing State to Modifier, not the other way around.
When do you need Modifier.composed { ... }?
fun Modifier.composedModifier(aboutState: AboutState) = composed(
factory = {
val color = remember { getRandomColor() }
aboutState.color = color
Modifier.background(aboutState.color)
}
)
In this example even if it's not practical getRandomColor is created once in recomposition and same color is used.
A zoom modifier i use for zooming in this library is as
fun Modifier.zoom(
key: Any? = Unit,
consume: Boolean = true,
clip: Boolean = true,
zoomState: ZoomState,
onGestureStart: ((ZoomData) -> Unit)? = null,
onGesture: ((ZoomData) -> Unit)? = null,
onGestureEnd: ((ZoomData) -> Unit)? = null
) = composed(
factory = {
val coroutineScope = rememberCoroutineScope()
// Current Zoom level
var zoomLevel by remember { mutableStateOf(ZoomLevel.Min) }
// Rest of the code
},
inspectorInfo = {
name = "zoom"
properties["key"] = key
properties["clip"] = clip
properties["consume"] = consume
properties["zoomState"] = zoomState
properties["onGestureStart"] = onGestureStart
properties["onGesture"] = onGesture
properties["onGestureEnd"] = onGestureEnd
}
)
Another practical example for this is Modifier.scroll that uses rememberCoroutineScope(), you can also remember object too to not intantiate another object in recomposition
#OptIn(ExperimentalFoundationApi::class)
private fun Modifier.scroll(
state: ScrollState,
reverseScrolling: Boolean,
flingBehavior: FlingBehavior?,
isScrollable: Boolean,
isVertical: Boolean
) = composed(
factory = {
val overscrollEffect = ScrollableDefaults.overscrollEffect()
val coroutineScope = rememberCoroutineScope()
// Rest of the code
},
inspectorInfo = debugInspectorInfo {
name = "scroll"
properties["state"] = state
properties["reverseScrolling"] = reverseScrolling
properties["flingBehavior"] = flingBehavior
properties["isScrollable"] = isScrollable
properties["isVertical"] = isVertical
}
)
I am practicing JetPack Compose with a pet app and I'm trying to observe a loading state in a Splash screen via LiveData. But, inside my composable I am asked to provide viewLifecycleOwner which seems impossible inside a composable. Or do I need to pass it down from the MainActivity? Seems clunky, is there another, more Jetpacky way?
#Composable
fun SplashScreen(navController: NavHostController, isLoadingInit: LiveData<Boolean>) {
val scale = remember {
Animatable(0f)
}
LaunchedEffect(key1 = true) {
scale.animateTo(
targetValue = 0.5f,
animationSpec = tween(
durationMillis = 500,
easing = {
OvershootInterpolator(2f).getInterpolation(it)
}
)
)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(id = R.drawable.pokeball),
contentDescription = "Pokemon Splashscreen",
modifier = Modifier.scale(scale.value)
)
}
isLoadingInit.observe(**viewLifecycleOwner**) {
navController.navigate("main-screen")
}
}
You can convert your LiveData to State using LiveData.observeAsState() extension function. Also instead of passing a LiveData as a parameter to compose, prefer converting it to a State first and then pass that as a parameter.
// This is probably what you are doing right now (inside a NavGraph)
val isLoadingInit = viewModel.isLoadingInit
SplashScreen(navController, isLoadingInit)
Change it to:
val isLoadingInit by viewModel.isLoadingInit.observeAsState()
SplashScreen(navController, isLoadingInit)
And then,
#Composable
fun SplashScreen(navController: NavHostController, isLoadingInit: Boolean) {
LaunchedEffect(isLoadingInit) {
if(!isLoadingInit) // Or maybe its negation
navController.navigate("main-screen")
}
...
}
I have VideoListScreen with LazyColumn and as my item I use VideoItem. This LazyColumn it's created with grid items to have lazy grid view with Category header. Tag is tag of category. Category details is information about category colors, title etc.:
#Composable
fun VideoItem(
videoPath: String,
brush: Brush,
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val assetFileDescriptor = LocalContext.current.assets.open(videoPath)
Surface(
modifier = modifier
.padding(5.dp)
.aspectRatio(1f)
.clickable { onClick() },
shape = Shapes.small,
elevation = 1.dp
) {
GlideImage(
imageModel = assetFileDescriptor.readBytes(),
contentScale = ContentScale.Crop,
requestOptions = RequestOptions.diskCacheStrategyOf(DiskCacheStrategy.NONE),
shimmerParams = ShimmerParams(
baseColor = MaterialTheme.colors.background,
highlightColor = Blue200,
durationMillis = 650,
dropOff = 0.65f,
tilt = 20f
)
)
Box(modifier = Modifier
.background(brush)
.fillMaxSize() )
}
}
VideoListScreen:
#Composable
fun VideoListScreen(
navController: NavHostController,
tag: String
) {
val cells = 2
val context = LocalContext.current
val categoryDetails = getCategoryDetailsBy(tag)
val videos = fetchVideos(context, tag)
LazyColumn(contentPadding = PaddingValues(5.dp)) {
item {
CategoryElement(
categoryDetails = categoryDetails,
modifier = Modifier
.fillMaxWidth()
.height(130.dp)
.padding(5.dp),
customTitle = "O kategorii"
)
}
gridItems(videos, cells) { assetFileName ->
val videoPath = "$tag/$assetFileName"
VideoItem(
videoPath = videoPath,
brush = categoryDetails.transparentBrush
) { navController.navigateToPlayer(videoPath) } //onClick function
}
}
}
private fun fetchVideos(context: Context, tag: String): List<String> {
return context.resources.assets.list("$tag/")?.toList() ?: listOf()
}
gridItems extension function:
fun <T> LazyListScope.gridItems(
data: List<T>,
cells: Int,
itemContent: #Composable BoxScope.(T) -> Unit,
) {
items(data.chunked(cells)) { row ->
Row(Modifier.fillMaxWidth()) {
for ((index, item) in row.withIndex()) {
Box(Modifier.fillMaxWidth(1f / (cells - index))) {
itemContent.invoke(this, item)
}
}
}
}
}
The problem is that when I try to apply clickability on this item (no matter where) the thumbnail loading (from the assets) becomes almost twice as slow. What's interesting when onClick function is empty, performance issue disappearing. In function called "navigateToPlayer(videoPath)" I navigate to another screen and send "videoPath" with navController.
If you have any questions, feel free to ask!
In compose you're creating UI with view builders. This function can be called many times, when you start using animations it even can be recomposed on each frame.
That's why you shouldn't perform any heavy work directly in composable function. And if you do, you need to store the result so you don't need recalculation on the next recomposition.
Both fetchVideos and assets.open are quite heavy operations, and even result of getCategoryDetailsBy(not sure what's that) should be cached. To do that you need to use remember or rememberSaveable. Check out how are these different and more about state in composables.
So update your declarations like this:
val categoryDetails = remember { getCategoryDetailsBy(tag) }
val videos = remember { fetchVideos(context, tag) }
val context = LocalContext.current
val assetFileDescriptor = remember { context.assets.open(videoPath) }
when I use CompositionLocal, I have got the data from the parent and modify it, but I found it would not trigger the child recomposition.
I have successfully change the data, which can be proved through that when I add an extra state in the child composable then change it to trigger recomposition I can get the new data.
Is anybody can give me help?
Append
code like below
data class GlobalState(var count: Int = 0)
val LocalAppState = compositionLocalOf { GlobalState() }
#Composable
fun App() {
CompositionLocalProvider(LocalAppState provides GlobalState()) {
CountPage(globalState = LocalAppState.current)
}
}
#Composable
fun CountPage(globalState: GlobalState) {
// use it request recomposition worked
// val recomposeScope = currentRecomposeScope
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.clickable {
globalState.count++
// recomposeScope.invalidate()
}) {
Text("count ${globalState.count}")
}
}
I found a workaround is using currentRecomposable to force recomposition, maybe there is a better way and pls tell me.
The composition local is a red herring here. Since GlobalScope is not observable composition is not notified that it changed. The easiest change is to modify the definition of GlobalState to,
class GlobalState(count: Int) {
var count by mutableStateOf(count)
}
This will automatically notify compose that the value of count has changed.
I am not sure why you are using compositionLocalOf in this way.
Using the State hoisting pattern you can use two parameters in to the composable:
value: T: the current value to display.
onValueChange: (T) -> Unit: an event that requests the value to change where T is the proposed new value.
In your case:
data class GlobalState(var count: Int = 0)
#Composable
fun App() {
var counter by remember { mutableStateOf(GlobalState(0)) }
CountPage(
globalState = counter,
onUpdateCount = {
counter = counter.copy(count = counter.count +1)
}
)
}
#Composable
fun CountPage(globalState: GlobalState, onUpdateCount: () -> Unit) {
BoxWithConstraints(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
.clickable (
onClick = onUpdateCount
)) {
Text("count ${globalState.count}")
}
}
You can declare your data as a MutableState and either provide separately the getter and the setter or just provide the MutableState object directly.
internal val LocalTest = compositionLocalOf<Boolean> { error("lalalalalala") }
internal val LocalSetTest = compositionLocalOf<(Boolean) -> Unit> { error("lalalalalala") }
#Composable
fun TestProvider(content: #Composable() () -> Unit) {
val (test, setTest) = remember { mutableStateOf(false) }
CompositionLocalProvider(
LocalTest provides test,
LocalSetTest provides setTest,
) {
content()
}
}
Inside a child component you can do:
#Composable
fun Child() {
val test = LocalTest.current
val setTest = LocalSetTest.current
Column {
Button(onClick = { setTest(!test) }) {
Text(test.toString())
}
}
}