Updated - see in the bottom of the question.
We are trying to add content to a sticky topbar defined from each screen, and the solution works.
But our custom modifier clickableWithScaleAnimation breaks when ever we add add a composable method to the stickyTopBar in ActualScreenComposable in the example below. It does just not scale or click, but if we add a simple .clickable {} after it, that will still work, so it is quite strange.
I suspect that the problem is related to the way of saving a composable method in a state, but it was working fine untill we spotted this exact problem. If we remove the stickyTopBar { ... } call it works, so it must be something with the was the state is set or handled. Any clues?
Scaffold(...) {
Column {
NavigationGraph(...)
}
}
fun NavigationGraph(...) {
val stickyTopBarContent = remember { mutableStateOf<(#Composable () -> Unit)?>(null) }
stickyTopBarContent.value?.let {
it.invoke()
}
AnimatedNavHost {
screenComposable(actuallScreenConfig, stickyTopBarContent)
...
}
}
fun screenComposable(screenConfig, stickyTopBarContent, ...) {
if (!screenConfig.supportsStickyTopBar) {
stickyTopBarContent.value = null
}
ActualScreenComposable(stickyTopBar = { stickyTopBarContent.value = it }, ...)
}
fun ActualScreenComposable(stickyTopBar: (#Composable () -> Unit) -> Unit, ...) {
stickyTopBar {
StickyTopBar() // it even fails with no content in here
}
content
}
This is the modifier clickableWithScaleAnimation that does not work if the stickyTopBar { ... } is called.
fun content() {
val coroutineScope = rememberCoroutineScope()
Column(modifier.clickableWithScaleAnimation(
coroutineScope = coroutineScope,
scale = scale,
scaleDownTo = 0.9f,
animationDuration = 200,
onClick = {
onSelected(...)
}
))
// .clickable { onSelected(...) } // this will work if added
{
...
}
}
fun Modifier.clickableWithScaleAnimation(
interactionSource: MutableInteractionSource = MutableInteractionSource(),
coroutineScope: CoroutineScope,
scale: Animatable<Float, AnimationVector1D>,
animationDuration: Int = 200,
scaleDownTo: Float = 0.9f,
onClick: () -> Unit
) = scale(scale.value)
.clickable(interactionSource = interactionSource, indication = null) {
coroutineScope.launch {
scale.animateTo(scaleDownTo, animationSpec = tween(animationDuration))
onClick()
scale.animateTo(1f, animationSpec = tween(animationDuration))
}
}
We are using Compose 1.3.0
Update 1:
The problem is related to the clickable modifier. The clicked callback is never called. Our goal is just to remove the indication:
.clickable(interactionSource = interactionSource, indication = null) { ... } // fails
.clickable() { ... } // works
So there must be something in that specific modifier that somehow breaks - any clue what it could be?
It is possible to solve like this with a CompositionLocalProvider to hide the indication and then use the clickable modifier without arguments, but it does not explain the cause of the problem.
fun content() {
CompositionLocalProvider(LocalIndication provides NoIndication) {
val coroutineScope = rememberCoroutineScope()
Column(modifier.clickableWithScaleAnimation(
coroutineScope = coroutineScope,
scale = scale,
scaleDownTo = 0.9f,
animationDuration = 200,
onClick = {
onSelected(...)
}
))
}
}
object NoIndication : Indication {
private object NoIndicationInstance : IndicationInstance {
override fun ContentDrawScope.drawIndication() {
drawContent()
}
}
#Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
return NoIndicationInstance
}
}
Related
The below code works as desired: the canvas gets recomposed each time the user either clicks the canvas itself or clicks the topBar icon, no matter how many times or in what order. In addition, the state variable value reveals something I want to know: where the user clicked. (Values 0 and 1 mean the icon was clicked and values 2 and 3 mean the canvas).
However, if the canvasState and iconState variables are set to their respective V1 functions instead of the V2 functions, then clicking the canvas or icon multiple times in a row is not detected. Apparently this is because the V1 functions can re-assign the same value to the state variable, unlike the V2 functions.
Since I'm using the neverEqualPolicy(), I thought I didn't have to assign a different value to the state variable to trigger a recompose. As a noob to Kotlin and Compose, what am I misunderstanding?
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
#Composable
fun MyApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
val canvasStateV1 = { state = 0 }
val iconStateV1 = { state = 2 }
val canvasStateV2 = { state = if (state == 0) { 1 } else { 0 } }
val iconStateV2 = { state = if (state == 2) { 3 } else { 2 } }
val iconState = iconStateV2
val canvasState = canvasStateV2
Scaffold(
topBar = { TopBar(canvasState) },
content = { padding ->
Column(Modifier.padding(padding)) {
Screen(state, iconState)
}
}
)
}
#Composable
fun TopBar(iconState: () -> Unit) {
TopAppBar(
title = { Text("This is a test") },
actions = {
IconButton(onClick = { iconState() }) {
Icon(Icons.Filled.AddCircle, null)
}
}
)
}
#Composable
fun Screen(state: Int, canvasState: () -> Unit) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.aspectRatio(ratio = 1f)
.background(color = MaterialTheme.colors.onSurface)
.pointerInput(Unit) {
detectTapGestures(
onTap = { canvasState() },
)
}
) {
Canvas(
modifier = Modifier.fillMaxSize().clipToBounds()
) {
Log.d("Debug", "Canvas: state = $state")
}
}
}
}
I didn't know other things to try to get the neverEqualPolicy() to work as expected.
I think the main reason for this is because the function Screen() is skippable. If you add the state as a MutableState instead of the Int itself, you will see that the Log.d gets called each time the state value gets updated. Same goes for merging the Screen() function into Column in MyApp
Compose analyses each function during build time. The screen functions receives an integer value, this is an immutable value, so the function itself becomes skippable.
To analyse which function is skippable/stable (and which is not), you can run a report during the build phase
This repo shows how
EDIT:
In this example you have two buttons, one changes the value, one just sets the same value. When setting the same value, you only see the Log.d of the local recomposition. When changing the state value, you see two log lines. the local and external both go through the recomposition.
#Composable
fun StackOverflowApp() {
var state by remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state = state }) {
Text(text = "State same value")
}
Button(onClick = { state += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = $state")
Log.d("TAG","Recomposition local")
ExternalText(state)
}
}
/**
* A skippable function
*
* restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ExternalText(
stable state: Int
)
*/
#Composable
fun ExternalText(state: Int){
Text(text = "[external] current State = $state")
Log.d("TAG","Recomposition external")
}
You can also pass the MutableState instead of the int value itself, when you pass the mutableState, the neverEqualPolicy is still in play. Each interaction fires both log lines
#Composable
fun StackOverflowApp() {
var state = remember { mutableStateOf(value = 0, policy = neverEqualPolicy()) }
Column() {
Button(onClick = { state.value = state.value }) {
Text(text = "State same value")
}
Button(onClick = { state.value += 1 }) {
Text(text = "State up")
}
Text(text = "[local] current State = ${state.value}")
Log.d("TAG","Recomposition internal")
ExternalText(state)
}
}
#Composable
fun ExternalText(state: MutableState<Int>){
Text(text = "[external] current State = ${state.value}")
Log.d("TAG","Recomposition external")
}
I am trying to solve this problem. Basically I'm creating a function in which I will put a webview.
Both above this function and below this function there will be other UI components.
when I scroll up the part of the UI above the webview shrinks to half of its height.
What I do is to record the page scroll events to make this animation.
Unfortunately, however, it happens that the webview does not ONLY occupy the space available on the screen but in height it is as high as all the contents of the WebView. Below you can see what I would like to achieve in the first image:
And here instead what I actually get:
This is a problem because when a page asks to accept cookies and the relevant popup appears, being the page so high I get an overlay that looks like an error but is simply given by the popup that asks you to accept cookies and which however is below, and on some devices it is not even visible
What I am trying to achieve, unsuccessfully, would be the XML equivalent of 0dp, in height.
To do this I thought I could use ConstraintLayouts for compose (implementation ("androidx.constraintlayout:constraintlayout-compose:1.0.0")) but they are not working. In fact, the result I get is as if I had "wrap content" and not 0dp and in fact a small page shrinks the box, a large page expands beyond the size of the screen.
Below is the code I am using. I started with the new compose feature whose example is well explained in this link
private val ToolbarHeight = 300.dp
#Composable
fun CollapsingToolbarComposeViewComposeNestedScrollInteropSample(content: #Composable () -> Unit) {
val toolbarHeightPx = with(LocalDensity.current) { ToolbarHeight.roundToPx().toFloat() }
val toolbarOffsetHeightPx = remember { mutableStateOf(0f) }
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.y
val newOffset = toolbarOffsetHeightPx.value + delta
toolbarOffsetHeightPx.value = newOffset.coerceIn(-toolbarHeightPx, 0f)
logger.debug("Offset.Zero : ${Offset.Zero} .... $newOffset, $delta")
return Offset.Zero
}
}
}
// Compose Scrollable
Box(
Modifier
.fillMaxSize()
.nestedScroll(nestedScrollConnection)
) {
// Android View
AndroidView(
factory = { context -> AndroidViewWithCompose(context, content) },
modifier = Modifier.fillMaxWidth()
)
TopAppBar(
modifier = Modifier
.height(ToolbarHeight)
.offset {
IntOffset(
x = 0,
y = toolbarOffsetHeightPx.value
.coerceAtLeast(-200f)
.coerceAtMost(0f)
.roundToInt()
)
},
title = { Text("toolbar offset is ${toolbarOffsetHeightPx.value}") }
)
}
}
private fun AndroidViewWithCompose(context: Context, content: #Composable () -> Unit): View {
return LayoutInflater.from(context)
.inflate(R.layout.three_fold_nested_scroll_interop, null).apply {
with(findViewById<ComposeView>(R.id.compose_view)) {
// Compose
setContent { LazyColumnWithNestedScrollInteropEnabled(content) }
}
}.also {
ViewCompat.setNestedScrollingEnabled(it, true)
}
}
#OptIn(ExperimentalComposeUiApi::class)
#Composable
private fun LazyColumnWithNestedScrollInteropEnabled(content: #Composable () -> Unit) {
ConstraintLayout() {
val (wv) = createRefs()
LazyColumn(
modifier = Modifier
.constrainAs(wv) {
top.linkTo(parent.top)
bottom.linkTo(parent.top)
}
.nestedScroll(
rememberNestedScrollInteropConnection()
),
contentPadding = PaddingValues(top = ToolbarHeight)
) {
item {
Text("This is a Lazy Column")
}
items(1) { _ ->
Box(modifier = Modifier.fillMaxSize()
) {
content()
}
}
}
}
}
the content() is just my webView:
AndroidView(
factory = {
WebView(it).apply {
webViewClient = WebViewClient()
settings.javaScriptEnabled = true
loadUrl("https://stackoverflow.com/posts/72259954/edit")
}
}
)
I use the SwipeRefresh composable from the accompanist library, I checked the examples, but I could not find sample that matches my needs. I want to implement element that is hidden above the main UI, so when the user starts swiping the box is slowly shown up. I implemented this logic by setting the padding of the box that is below the hidden box. The obvious reason is that by changing the padding, all the composables are recreated and that leads to lags as seen from this report. Is there a way to fix it?
#Composable
fun HomeScreen(viewModel: CityWeatherViewModel) {
val scrollState = rememberScrollState()
val maxTopOffset = 200f
SwipeRefresh(
state = rememberSwipeRefreshState(isRefreshing = isRefreshing),
onRefresh = {
},
indicator = { state, triggerDp ->
if (state.isRefreshing) {
} else {
val triggerPx = with(LocalDensity.current) { triggerDp.toPx() }
val progress = (state.indicatorOffset / triggerPx).coerceIn(0f, 1f)
viewModel.apply {
rotateSwipeRefreshArrow(progress >= 0.9)
setSwipeRefreshTopPadding(progress * maxTopOffset)
}
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = scrollState, enabled = true)
.padding(top = viewModel.swipeRefreshPaddingTop.value.dp)
) {
HiddenSwipeRefreshBox(viewModel)
MainBox(viewModel)
}
}
}
#Composable
fun HiddenSwipeRefreshBox(viewModel: CityWeatherViewModel) {
}
#Composable
fun MainBox(viewModel: CityWeatherViewModel) {
}
#HiltViewModel
class CityWeatherViewModel #Inject constructor(
private val getCityWeather: GetCityWeather
) : ViewModel() {
private val _swipeRefreshPaddingTop = mutableStateOf(0f)
val swipeRefreshPaddingTop: State<Float> = _swipeRefreshPaddingTop
fun setSwipeRefreshTopPadding(padding: Float) {
_swipeRefreshPaddingTop.value = padding
}
}
I managed to fix it, by replacing the padding with offset, so the code for the Column is changed to:
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(state = scrollState, enabled = true)
.offset { IntOffset(0, viewModel.swipeRefreshPaddingTop.value.roundToInt()) }
) {
}
And now there is no more lagging, even on older devices with 2GB RAM! I have no idea how this is related to the padding lagging, but it works. I found the code from HERE
The Code A is from the offical sample project here.
The Code B is from Android Studio source code.
I have searched the article about the function key by Google, but I can't find more details about it.
How can Android Studio launch the inline fun <T> key()? Why can't the author use Code C to launch directly?
Code A
key(detailPost.id) {
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
}
Code B
#Composable
inline fun <T> key(
#Suppress("UNUSED_PARAMETER")
vararg keys: Any?,
block: #Composable () -> T
) = block()
Code C
LazyColumn(
state = detailLazyListState,
contentPadding = contentPadding,
modifier = Modifier
.padding(horizontal = 16.dp)
.fillMaxSize()
.notifyInput {
onInteractWithDetail(detailPost.id)
}
) {
stickyHeader {
val context = LocalContext.current
PostTopBar(
isFavorite = hasPostsUiState.favorites.contains(detailPost.id),
onToggleFavorite = { onToggleFavorite(detailPost.id) },
onSharePost = { sharePost(detailPost, context) },
modifier = Modifier.fillMaxWidth().wrapContentWidth(Alignment.End)
)
}
postContentItems(detailPost)
}
From key documentation:
key is a utility composable that is used to "group" or "key" a block of execution inside of a composition. This is sometimes needed for correctness inside of control-flow that may cause a given composable invocation to execute more than once during composition.
It also contains several examples, so check it out.
Here is a basic example of the usefulness of it. Suppose you have the following Composable. I added DisposableEffect to track its lifecycle.
#Composable
fun SomeComposable(text: String) {
DisposableEffect(text) {
println("appear $text")
onDispose {
println("onDispose $text")
}
}
Text(text)
}
And here's usage:
val items = remember { List(10) { it } }
var offset by remember {
mutableStateOf(0)
}
Button(onClick = {
println("click")
offset += 1
}) {
}
Column {
items.subList(offset, offset + 3).forEach { item ->
key(item) {
SomeComposable(item.toString())
}
}
}
I only display two list items, and move the window each time the button is clicked.
Without key, each click will remove all previous views and create new ones.
But with key(item), only the disappeared item disappears, and the items that are still on the screen are reused without recomposition.
Here are the logs:
appear 0
appear 1
appear 2
click
onDispose 0
appear 3
click
onDispose 1
appear 4
click
onDispose 2
appear 5
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())
}
}
}