JetBrains / compose-multiplatform

Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable.
https://jetbrains.com/lp/compose-multiplatform
Apache License 2.0
15.88k stars 1.15k forks source link

[iOS] Performance discrepancies in text field behavior: Battery-Saving mode impact #3999

Open Lukaszz112 opened 9 months ago

Lukaszz112 commented 9 months ago

Describe the bug When entering the text field in battery-saving mode, it behaves strangely, jumping and freezing a lot. However, after turning off this mode, everything works much smoother, albeit with slightly noticeable vibrations.

Affected platforms Select one of the platforms below:

Versions

Expected behavior I expected the text field to function smoothly without any erratic behavior or freezing, regardless of whether the device was in battery-saving mode or not.

https://github.com/JetBrains/compose-multiplatform/assets/93291505/30966fa2-c1ef-4822-8f4f-31f5edff1f53

Code snippet

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.unit.dp
import co.proexe.blueonline.ui.multiplatform.components.ClearFocusBox
import co.proexe.blueonline.ui.multiplatform.components.buttons.LargeButton
import co.proexe.blueonline.ui.multiplatform.components.text.TextInput
import co.proexe.blueonline.ui.multiplatform.components.userprofile.UserIcon
import co.proexe.blueonline.ui.multiplatform.components.userprofile.UserProfileTopBar
import co.proexe.blueonline.ui.multiplatform.platform.Res
import co.proexe.blueonline.ui.multiplatform.theme.caption1TextStyle
import co.proexe.blueonline.ui.multiplatform.theme.h3TextStyle
import co.proexe.blueonline.ui.multiplatform.util.StartEffect
import co.proexe.blueonline.userprofile.vm.UserProfileViewModel
import org.jetbrains.compose.resources.ExperimentalResourceApi
import org.jetbrains.compose.resources.painterResource

@Composable
fun PersonalInformationScreen(
    viewModel: UserProfileViewModel,
    onBackAction: () -> Unit
) {
    StartEffect {
        viewModel.onStart()
    }

    ClearFocusBox {
        PersonalInformationScreenContent(
            onBackAction,
            viewModel
        )
    }
}

@Suppress("LongMethod")
@Composable
fun PersonalInformationScreenContent(
    onBackAction: () -> Unit,
    viewModel: UserProfileViewModel
) {
    val firstName = viewModel.uiState.userData?.firstName ?: " "
    val secondName = viewModel.uiState.userData?.lastName ?: " "
    val userId = viewModel.uiState.userData?.id ?: " "
    val email = viewModel.uiState.userData?.email ?: " "

    val firstNameText = remember { mutableStateOf("") }
    val secondNameText = remember { mutableStateOf("") }
    val countryText = remember { mutableStateOf("") }

    Scaffold(
        topBar = {
            UserProfileTopBar(
                modifier = Modifier.padding(top = topPadding),
                text = "Personal information",
                onBack = {
                    onBackAction()
                }
            )
        },
        containerColor = Color.Black,
        contentColor = Color.White
    ) { padding ->
        Column(
            modifier = Modifier
                .padding(
                    top = padding.calculateTopPadding(),
                    start = 25.dp,
                    end = 25.dp
                ).verticalScroll(rememberScrollState()),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Spacer(Modifier.height(41.dp))
            Row(
                modifier = Modifier.height(60.dp)
            ) {
                UserIcon(
                    firstName = firstName,
                    secondName = secondName,
                    size = 54.dp
                )
                Spacer(Modifier.width(12.dp))
                Column(
                    modifier = Modifier.fillMaxSize(),
                    horizontalAlignment = Alignment.Start,
                    verticalArrangement = Arrangement.Center
                ) {
                    Text(
                        text = "$firstName $secondName",
                        style = h3TextStyle,
                        color = MaterialTheme.colorScheme.onSecondary
                    )
                    Text(
                        text = "UserID: $userId",
                        style = caption1TextStyle,
                        color = MaterialTheme.colorScheme.onSecondary.copy(alpha = 0.3f)
                    )
                }
            }
            Spacer(Modifier.height(38.dp))
            PersonalInformationButtons(
                firstName = firstNameText,
                secondName = secondNameText,
                countryText = countryText,
                navigateToCountriesSearchAction = {},
                email = email
            )
            Spacer(
                Modifier.height(16.dp)
            )
            LargeButton(
                text = "Zapisz zmiany",
                onClick = {
                    viewModel.updateUserMetadata(
                        firstNameText.value,
                        secondNameText.value
                    )
                }
            )
        }
    }
}

@OptIn(ExperimentalResourceApi::class)
@Suppress("LongMethod")
@Composable
fun PersonalInformationButtons(
    firstName: MutableState<String>,
    secondName: MutableState<String>,
    countryText: MutableState<String>,
    email: String,
    navigateToCountriesSearchAction: (String) -> Unit
) {
    val focusManager = LocalFocusManager.current
    var isNavigateToCountriesSearchClicked by remember { mutableStateOf(false) }

    Column(
        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TextInput(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp),
            text = mutableStateOf(email),
            label = "Adres email *",
            isSingleLine = true,
            keyboardActions = KeyboardActions {
                focusManager.moveFocus(FocusDirection.Next)
            },
            isEnabled = false,
        )
        TextInput(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp),
            text = firstName,
            label = "Imię *",
            isSingleLine = true,
            keyboardActions = KeyboardActions {
                focusManager.moveFocus(FocusDirection.Next)
            },
            onValueChangedAction = {
                firstName.value = it
            }
        )
        TextInput(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp),
            text = secondName,
            label = "Nazwisko *",
            isSingleLine = true,
            keyboardActions = KeyboardActions {
                focusManager.moveFocus(FocusDirection.Next)
            },
            onValueChangedAction = {
                secondName.value = it
            }
        )
        TextInput(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp)
                .onFocusChanged {
                    if (!isNavigateToCountriesSearchClicked && it.hasFocus) {
                        isNavigateToCountriesSearchClicked = true
                        navigateToCountriesSearchAction(countryText.value)
                    }
                },
            text = countryText,
            label = "Kraj przebywania *",
            isSingleLine = true,
            isReadOnly = true,
            trailingIcon = {
                Column(verticalArrangement = Arrangement.Center) {
                    Icon(
                        painter = painterResource(Res.images.ARROW_DOWN),
                        contentDescription = null
                    )
                }
            },
            keyboardActions = KeyboardActions {
                focusManager.moveFocus(FocusDirection.Next)
            },
            onValueChangedAction = {
                countryText.value = it
            }
        )
        TextInput(
            modifier = Modifier
                .fillMaxWidth()
                .padding(top = 8.dp)
                .onFocusChanged {
                    if (!isNavigateToCountriesSearchClicked && it.hasFocus) {
                        isNavigateToCountriesSearchClicked = true
                        navigateToCountriesSearchAction(countryText.value)
                    }
                },
            text = countryText,
            label = "Data urodzenia",
            isSingleLine = true,
            isReadOnly = true,
            trailingIcon = {
                Column(verticalArrangement = Arrangement.Center) {
                    Icon(
                        painter = painterResource(Res.images.BIRTHDATE),
                        contentDescription = null
                    )
                }
            },
            keyboardActions = KeyboardActions {
                focusManager.moveFocus(FocusDirection.Next)
            },
            onValueChangedAction = {
                countryText.value = it
            }
        )
    }
}
alexzhirkevich commented 9 months ago

Performance in the power saving mode sucks totally :). Not only the text fields. On my 12 mini FPS drops are terrible even in prod mode. And as i see on your clip, text fields jump in a default mode too, so the reason is not likely PS mode

jstarczewski commented 9 months ago

@alexzhirkevich What can be the reason of the jumps, as I am also facing this type of issues in one of the projects?

mazunin-v-jb commented 9 months ago

Hello, @Lukaszz112! Thanks for submitting the issue. Unfortunately, I couldn't reproduce that. Could you please try to recreate that in a separate project from our template and link it here?

Upd. @jstarczewski, I'm sorry, I mentioned you wrong yesterday. But your example will help too if you're facing the same issue.

jstarczewski commented 8 months ago

@mazunin-v-jb Hey, sorry for the late response. We were able to fix the problem. The cause was the SwiftUI code. Basically we spot a weird behaviour when we try to .ignoreSafeAreas but the Compose Multiplatform UI is embedded in VStack (called later container) with some sort of Bottom Bar on iOS. As the keyboard is showing/hiding seems like the Compose code container is sort of "squeezed" to "springs" (the same behaviour and similar case is visible on the video above). The fix was to move the Swift bottom bar as an overlay over compose container. Then when Keyboard shows nothing moves :)

I can try to recreate simple samples if you want :)

dima-avdeev-jb commented 8 months ago

@jstarczewski It is good to have minimal reproducer of this problem. Thanks!

Can you please to do so?

okushnikov commented 1 month ago

Please check the following ticket on YouTrack for follow-ups to this issue. GitHub issues will be closed in the coming weeks.