In this app, I have a screen where you can enter a title and content for a Note.
The screen has two composables DetailScreen() and DetailScreenContent.
Detailscreen has the scaffold and appbars and calls DetailScreenContents() which has two TextFields and a button.
I'm expecting the user to enter text in these fields and then press the button which will package the text into a NOTE object. My question is, how to pass the NOTE to the upper composable which is DETAILSCREEN() with a callback like=
onclick: -> Note or any other efficient way?
#Composable
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent()
}
}
#Composable
fun DetailScreenContent() {
val titleValue = remember { mutableStateOf("")}
val contentValue = remember { mutableStateOf("")}
val endnote by remember{ mutableStateOf(Note(
Title = titleValue.value,
Content = contentValue.value))}
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(value = titleValue.value,
onValueChange = {titleValue.value = it},
singleLine = true,
label = {Text("")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp),
shape = cardShapes.small
)
OutlinedTextField(value = contentValue.value, onValueChange = {
contentValue.value = it
},
label = {Text("Content")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp, top = 3.dp)
.height(200.dp),
shape = cardShapes.small,
)
Row(horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()){
Button(onClick = {
/**return the object to the upper composable**/
}, shape = cardShapes.small) {
Text(text = stringResource(R.string.Finish))
}
}
}
You could use state hoisting. Using lambdas is the most common way of hoisting state here.
Ok so here's DetailScreenContent(), say
fun DetailScreenContent(
processNote: (Note) -> Unit
){
Button( onClick = { processNote(/*Object to be "returned"*/) }
}
We are not literally returning anything, but we are hoisting the state up the hierarchy. Now, in DetailsScreen
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent(
processNote = {note -> //This is the passed object
/*Perform operations*/
}
)
//You could also extract the processNote as a variable, like so
/*
val processNote = (Note) {
Reference the note as "it" here
}
*/
}
}
This assumes that there is a type Note (something like a data class or so, the object of which type is being passed up, get it?)
That's how we hoist our state and hoist it up to the viewmodel. Remember, compose renders state based on variables here, making it crucial to preserve the variables, making sure they are not modified willy nilly and read from random places. There should be, at a time, only one instance of the variables, which should be modified as and when necessary, and should be read from a common place. This is where viewmodels are helpful. You store all the variables (state) inside the viewmodel, and hoist the reads and modifications to there. It must act as a single source of truth for the app.
Related
I'm starting to learn about Jetpack Compose. I put together this app where I explore different day-to-day use cases, each of the feature modules within this project is supposed to tackle different scenarios.
One of this feature modules – the chatexample feature module, tries to implement a simple ViewPager where each of the pages is a Fragment, the first page "Messages" is supposed to display a paginated RecyclerView wrapped around a SwipeRefreshLayout. Now, the goal is to implement all this using Jetpack Compose. This is the issue I'm having right now:
The PullRefreshIndicator that I'm using to implement the Pull-To-Refresh action works as expected and everything seems pretty straightforward so far, but I cannot figure out why the ProgresBar stays there on top.
So far I've tried; Carrying on the Modifier from the parent Scaffold all the way through. Making sure I explicitly set the sizes to fit the max height and width. Add an empty Box in the when statement - but nothing has worked so far, I'm guessing I could just remove the PullRefreshIndicator if I see that the ViewModel isn't supposed to be refreshing, but I don't think that's the right thing to do.
To quickly explain the Composables that I'm using here I have:
<Surface>
<Scaffold> // Set with a topBar
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
// ChatExampleScreen
<Box> // A Box set with the pullRefresh modifier
// Depending on the ChatExamleViewModel we might pull different composables here
</PullRefreshIndicator>
</Box>
// Another ChatExampleScreen for the second tab
</HorizontalPager>
</Column>
<Scaffold>
</Surface>
Honestly, I don't get how the PullRefreshIndicator that is in a completely different Composable (ChatExampleScreen) gets to overlap with the ScrollableTabRow that is outside.
Hope this makes digesting the UI a bit easier. Any tip, advice, or recommendation is appreciated. Thanks! 🙇
Edit: Just to be completely clear, what I'm trying to achieve here is to have a PullRefreshIndicator on each page. Something like this:
On each page, you pull down, see the ProgressBar appear, and when it is done, it goes away, within the same page. Not overlapping with the tabs above.
A comparatively easier solution in my case was to simply give the Box that contains my vertically scrollable Composable and my PullRefreshIndicator a zIndex of -1f:
Box(Modifier.fillMaxSize().zIndex(-1f)) {
LazyColumn(...)
PullRefreshIndicator(...)
}
And that already did the trick for me. I have a very similar setup to the OP, a Scaffold containing a ScrollableTabRow and a HorizontalPager with refreshable lists on the individual tabs.
I want to leave my first answer as I feel it will still be useful to future readers, so heres another one you might consider.
One of the Box in the tabs has a scroll modifier though, because according to the Accompanist Docs and the actual functionality.
… The content needs to be 'vertically scrollable' for SwipeRefresh()
to be able to react to swipe gestures. Layouts such as LazyColumn are
automatically vertically scrollable, but others such as Column or
LazyRow are not. In those instances, you can provide a
Modifier.verticalScroll modifier…
It's from accompanist documentation about the migration of the API but it still applies to this current one in compose framework.
The way I understand it is a scroll event should be present for the PullRefresh to get activated manually (i.e a layout/container with a vertical scroll modifier or a LazyColumn), something that will consume a drag/swipe event in the screen.
Here's the short working sample. All of these are copy-and-paste-able.
Activity:
class PullRefreshActivity: ComponentActivity() {
private val viewModel: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
MyScreen(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
}
}
Some data classes:
data class MessageItems(
val message: String = "",
val author: String = ""
)
data class DashboardBanner(
val bannerMessage: String = "",
val content: String = ""
)
ViewModel:
class MyViewModel: ViewModel() {
var isLoading by mutableStateOf(false)
private val _messageState = MutableStateFlow(mutableStateListOf<MessageItems>())
val messageState = _messageState.asStateFlow()
private val _dashboardState = MutableStateFlow(DashboardBanner())
val dashboardState = _dashboardState.asStateFlow()
fun fetchMessages() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_messageState.update {
it.add(
MessageItems(
message = "Hello First Message",
author = "Author 1"
),
)
it.add(
MessageItems(
message = "Hello Second Message",
author = "Author 2"
)
)
it
}
isLoading = false
}
}
fun fetchDashboard() {
viewModelScope.launch {
isLoading = true
delay(2000L)
_dashboardState.update {
it.copy(
bannerMessage = "Hello World!!",
content = "Welcome to Pull Refresh Content!"
)
}
isLoading = false
}
}
}
Tab Screen Composables:
#Composable
fun MessageTab(
myViewModel : MyViewModel
) {
val messages by myViewModel.messageState.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(messages) { item ->
Column(
modifier = Modifier
.fillMaxWidth()
.border(BorderStroke(Dp.Hairline, Color.DarkGray)),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = item.message)
Text(text = item.author)
}
}
}
}
#Composable
fun DashboardTab(
myViewModel: MyViewModel
) {
val banner by myViewModel.dashboardState.collectAsState()
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
contentAlignment = Alignment.Center
) {
Column {
Text(
text = banner.bannerMessage,
fontSize = 52.sp
)
Text(
text = banner.content,
fontSize = 16.sp
)
}
}
}
Finally, the composable that contains the PullRefresh and the Pager/Tab components, and all of them are direct children of a ConstraintLayout. So to achieve a PullRefresh behind the Tabs but still on top of the HorizontalPager, first I had to put the HorizontalPager as the first child, the PullRefresh as the second and the Tabs as the last one, constraining them accordingly to preserve the visual arrangement of a Tab Pager.
#OptIn(ExperimentalMaterialApi::class, ExperimentalPagerApi::class)
#Composable
fun MyScreen(
modifier : Modifier = Modifier,
viewModel: MyViewModel
) {
val refreshing = viewModel.isLoading
val pagerState = rememberPagerState()
val pullRefreshState = rememberPullRefreshState(
refreshing = refreshing,
onRefresh = {
when (pagerState.currentPage) {
0 -> {
viewModel.fetchMessages()
}
1 -> {
viewModel.fetchDashboard()
}
}
},
refreshingOffset = 100.dp // just an arbitrary offset where the refresh will animate
)
ConstraintLayout(
modifier = modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
val (pager, pullRefresh, tabs) = createRefs()
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.constrainAs(pager) {
top.linkTo(tabs.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
height = Dimension.fillToConstraints
}
) { page ->
when (page) {
0 -> {
MessageTab(
myViewModel = viewModel
)
}
1 -> {
DashboardTab(
myViewModel = viewModel
)
}
}
}
PullRefreshIndicator(
modifier = Modifier.constrainAs(pullRefresh) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
refreshing = refreshing,
state = pullRefreshState,
)
ScrollableTabRow(
modifier = Modifier.constrainAs(tabs) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
},
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
},
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = {},
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = {},
text = {
Text(
text = "Dashboard"
)
}
)
}
}
}
output:
<Surface>
<Scaffold>
<ConstraintLayout>
// top to ScrollableTabRow's bottom
// start, end, bottom to parent's start, end and bottom
// 0.dp (view), fillToConstraints (compose)
<HorizontalPager>
<PagerScreens/>
</HorizontalPager>
// top, start, end of parent
<PullRefreshIndicator/>
// top, start and end of parent
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
</ConstraintLayout>
<Scaffold>
</Surface>
I think there's nothing wrong with the PullRefresh api and the Compose/Accompanist Tab/Pager api being used together, it seems like the PullRefresh is just respecting the placement structure of the layout/container it is put into.
Consider this code, no tabs, no pager, just a simple set-up of widgets that is identical to your set-up
Column(
modifier = Modifier.padding(it)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.background(Color.Blue)
)
val pullRefreshState = rememberPullRefreshState(
refreshing = false,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = Modifier.pullRefresh(pullRefreshState)
) {
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = false,
state = pullRefreshState,
)
}
}
What it looks like.
The PullRefresh is placed inside a component(Box) that is placed below another component in a Column vertical placement, and since it's below another widget, its initial position will not be hidden like the image sample.
With your set-up, since I noticed that the ViewModel is being shared by the tabs and also the reason why I was confirming if you are decided with your architecture is because the only fix I can think of is moving the PullRefresh up in the sequence of the composable widgets.
First changes I made is in your ChatExampleScreen composable, which ended up like this, all PullRefresh components are removed.
#Composable
fun ChatExampleScreen(
chatexampleViewModel: ChatExampleViewModel,
modifier: Modifier = Modifier
) {
val chatexampleViewModelState by chatexampleViewModel.state.observeAsState()
Box(
modifier = modifier
.fillMaxSize()
) {
when (val result = chatexampleViewModelState) {
is ChatExampleViewModel.State.SuccessfullyLoadedMessages -> {
ChatExampleScreenSuccessfullyLoadedMessages(
chatexampleMessages = result.list,
modifier = modifier,
)
}
is ChatExampleViewModel.State.NoMessagesFetched -> {
ChatExampleScreenEmptyState(
modifier = modifier
)
}
is ChatExampleViewModel.State.NoInternetConnectivity -> {
NoInternetConnectivityScreen(
modifier = modifier
)
}
else -> {
// Agus - Do nothing???
Box(modifier = modifier.fillMaxSize())
}
}
}
}
and in your Activity I moved all the setContent{…} scope into another function named ChatTabsContent and placed everything inside it including the PullRefresh components.
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun ChatTabsContent(
modifier : Modifier = Modifier,
viewModel : ChatExampleViewModel
) {
val chatexampleViewModelIsLoadingState by viewModel.isLoading.observeAsState()
val pullRefreshState = rememberPullRefreshState(
refreshing = chatexampleViewModelIsLoadingState == true,
onRefresh = { viewModel.fetchMessages() }
)
Box(
modifier = modifier
.pullRefresh(pullRefreshState)
) {
Column(
Modifier
.fillMaxSize()
) {
val pagerState = rememberPagerState()
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
indicator = { tabPositions ->
TabRowDefaults.Indicator(
modifier = Modifier.tabIndicatorOffset(
currentTabPosition = tabPositions[pagerState.currentPage],
)
)
}
) {
Tab(
selected = pagerState.currentPage == 0,
onClick = { },
text = {
Text(
text = "Messages"
)
}
)
Tab(
selected = pagerState.currentPage == 1,
onClick = { },
text = {
Text(
text = "Dashboard"
)
}
)
}
HorizontalPager(
count = 2,
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { page ->
when (page) {
0 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxSize()
)
}
1 -> {
ChatExampleScreen(
chatexampleViewModel = viewModel,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
PullRefreshIndicator(
modifier = Modifier.align(Alignment.TopCenter),
refreshing = chatexampleViewModelIsLoadingState == true,
state = pullRefreshState,
)
}
}
which ended up like this
setContent {
TheOneAppTheme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = { TopAppBarSample() }
) {
ChatTabsContent(
modifier = Modifier.padding(it),
viewModel = viewModel
)
}
}
}
}
Result:
Structural changes.
<Surface>
<Scaffold> // Set with a topBar
<Box>
<Column>
<ScrollableTabRow>
<Tab/> // Set for the first "Messages" tab
<Tab/> // Set for the second "Dashboard" tab
</ScrollableTabRow>
<HorizontalPager>
<Box/>
</HorizontalPager>
</Column>
// pull refresh is now at the most "z" index of the
// box, overlapping the content (tabs/pager)
<PullRefreshIndicator/>
</Box>
<Scaffold>
</Surface>
I haven't explored this API yet, but it looks like it should be used directly in a z-oriented layout/container parent such as Box as the last child.
I just want to share more details about the issue here and what the solution is. I appreciate a lot the solutions shared above and these were definitely key to figuring the problem out.
The bare-minimum solution here is to replace the Box with a ConstraintLayout in the ChatScreenExample composable:
Why? Because as #z.y shared above the PullRefreshIndicator needs to be contained on a "vertically scrollable" composable, and while the Box composable can be set with the vericalScroll() modifier we need to make sure we constraint the height of the content, that's why we had to change to a ConstraintLayout.
Feel free to correct me if I'm missing something.
There is yet another solution to this problem, which is using a .clipToBounds() modifier over the tab content container.
I have a ViewModel which uses Flow to get a Note object from my Room database:
var uiState by mutableStateOf(NoteUiState())
private set
private fun getNoteById(argument: Int) {
viewModelScope.launch {
try {
repository.getNoteById(argument).collect { note ->
uiState = NoteUiState(note = note)
}
} catch (e: Exception) {
uiState = NoteUiState(error = true)
}
}
}
Note class:
#Entity(tableName = "notes")
data class Note(
#PrimaryKey(autoGenerate = true) val id: Int = 0,
#ColumnInfo(name = "title") val title: String = "",
#ColumnInfo(name = "text") val text: String = "",
) {
override fun toString() = title
}
This approach works fine, until I try to make a mutable strings with the values of the Note object as their default so I can update 2 TextField composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
private fun Note(
note: DataNote,
isNewNote: Boolean,
createNote: (DataNote) -> Unit,
updateNote: (DataNote) -> Unit,
back: () -> Unit,
trued: String,
) {
var title by remember { mutableStateOf(note.title) }
var content by remember { mutableStateOf(note.text) }
TextField(
value = title,
onValueChange = { title = it },
modifier = Modifier
.fillMaxWidth(),
placeholder = { Text(text = "Title") },
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent
)
)
TextField(
value = content,
onValueChange = { content = it },
modifier = Modifier
.fillMaxWidth(),
placeholder = { Text(text = "Content") },
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent
)
)
}
For some reason the first time the Note object is called it's null, so I want a way to update the title and content variables.
The Note object itself updates without issue, however the title and content variables never change from the initial value. How can I update the title and content variables while also making them work for the textfield?
I found out how to make the Textfield work while also getting the inital value from the object. The issue was that the Note object was called as null on the first call, so the mutableStateFlow didnt get the initial values.
First, I had to pass the actual state as a MutableStateFlow to my composable:
#Composable
private fun Note(
state: MutableStateFlow<NoteUiState>,
createNote: (DataNote) -> Unit,
updateNote: (DataNote) -> Unit,
back: () -> Unit
) {
...
Next, I just had to get the Note object by calling collectAsState():
val currentNote = state.collectAsState().value.note
Finally, all that was needed was to pass the currentNote object text and title in the value of the Textfield, and on onValueChange to update the state object itself via a copy:
This is the complete solution:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
private fun Note(
state: MutableStateFlow<NoteUiState>,
createNote: (DataNote) -> Unit,
updateNote: (DataNote) -> Unit,
back: () -> Unit
) {
val currentNote = state.collectAsState().value.note
Column(Modifier.fillMaxSize()) {
TextField(
value = currentNote.title,
onValueChange = {
state.value = state.value.copy(note = currentNote.copy(title = it))
},
modifier = Modifier
.fillMaxWidth(),
placeholder = { Text(text = "Title") },
colors = TextFieldDefaults.textFieldColors(
containerColor = Color.Transparent
)
)
}
}
I'm not sure is this is a clean solution, but is the only way it worked for me, thanks for the feedback, opinions on this approach are always welcomed.
You should think about state hoisting and about having a single source of truth.
Really you need to define where your state will live, if on the viewmodel or on the composable functions.If you are only going to use the state (your note) on the ui then it's ok to hoist your state up to the Note composable function.
But if you need that state in something like another repo, insert it somewhere else or in general to do operations with it, the probably you should hoist it up to the viewmodel (you already have it there).
So use the property of your viewmodel directly in your composable and add a function in your viewmodel to mutate the state and pass this function to the onValueChanged lambda.
var title by remember { mutableStateOf(note.title) }
var content by remember { mutableStateOf(note.text) }
remember block executes only on 1st composition and then value will remembered until decomposition or u need to change it externally through '=' assignment operator,
.
instated of this
TextField(
value = title)
write this way
TextField(
value = note.title)
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'm trying to build a custom form using jetpack compose.
What I did so far in the Screen :
#Composable
fun FormContent(
viewModel: FormViewModel,
customFieldList: List<String>,
valuesCustomFieldsList: List<String>
) {
Column(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp)
) {
if (customFieldList.isNotEmpty()) {
customFieldList.forEachIndexed { index, item ->
TextRow(
title = item,
placeholder = "Insert $item",
value = valuesCustomFieldsList[index],
onValueChange = {
viewModel.onCustomFieldChange(index, it)
},
isError = false
)
}
}
}
}
Where customFieldList is the list of textFields i want and valuesCustomFieldsList is
val valuesCustomFields by viewModel.values.collectAsState()
In my viewModel the code is as it follows:
private val _values = MutableStateFlow(emptyList<String>())
val values : StateFlow<List<String>>
get() = _values
private var customFieldValues = mutableListOf<String>()
init {
viewModelScope.launch {
//get all custom fields
customFields = gmeRepository.getCustomField()
if (customFields.isNotEmpty()) {
//init the customFieldsValues
for (i in 0..customFields.size) {
customFieldValues.add("")
}
_values.value = customFieldValues
}
}
}
fun onCustomFieldChange(index : Int, value: String) {
customFieldValues[index] = value
_values.value = customFieldValues
}
What happens here is that if I try to change the value inside the inputText, onCustomFieldChange is correctly triggered with the first letter I wrote inside, but then no change is visible in the UI.
If I try to change another static field inside the form, then only the last char i wrote inside my custom fields are shown cause a recomposition is triggered.
Is there something I can do to achieve my goal?
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) }