LazyColumn items mutual exclusive - android-jetpack-compose

Say I have a list of items in a LazyColumn and they can all be selected. How can I make sure when an item is selected, the others are deselected (so only 1 item can be selected at once)?
I was thinking about interactionSource but I'm not really sure how use it.

I'd extract this logic into a ViewModel, with your items being a specific UIModel that contains your current data of the items + their state, containing the isSelected state.
For ex:
data class YourUiModel(val id: Long, val isSelected: Boolean = false, ...)
private val _items = MutableStateFlow(emptyList<YourUiModel>())
val items: StateFlow<List<YourUiModel>>
get() = _items
private fun changeSelectionTo(uiModel: YourUiModel) {
_items.value = items.value.map { it.copy(isSelected = it.id == uiModel.id) }
}
...

Just store a variable holding the index of the selected variable
var sel by mutableStateOf(0)
Now you may want to use remember here if you are declaring it in your Composables, but I would highly recommend storing it inside a viewmodel. A simple example would be
class VM: ViewModel(){
var sel by mutableStateOf(0) //Assuming 0 is selected initially, change it to -1 if you please
//Create a setter optionally, for state-hoisting
fun setSel(index: Int){
sel = index
}
}
In the MainActivity,
class MainActivity{
val vm by viewmodels<VM>()
setContent{
MySelectableList(
list = ...,
selected = vm.sel,
onSelectionChange = vm::setSel // Just pass the viewmodel if you did not create the setter
)
}
#Composable
fun MySelectableList(
list: List<Any>,
selected: Int,
onSelectionChange: (int) -> Unit // or vm, if you did not create the setter
){
list.forEachIndexed {index, item ->
// Now create this Composable yourself
ItemComposable(
isSelected = index == selected,
onClick = { onSelectionChange(index) } // Just focus and have a look at what's happening here
)
}
}
}
I am using state-hoisting here, read this for reference. Def take this codelab if you haven't already
You will need to do an additional check if you set the initial value of sel to -1 earlier. Completely understand the code that is flowing through here.

You can try use remember {...}
#Composable
fun MyComposeList(data: List<MyItemData>,...) {
val (selectedItem, setSelectedItem) = remember { mutableStateOf(null // or maybe data.first() } // remember current selected by state delegate
lazyColumn() { // your lazy column
items (data) { item -> // item = each item
// your compose item view
MyComposeItemView (
data = item, // pass data by each item
isSelected = data == selectedItem // set is item selected by default
) {
// on item click
setSelectedItem(it)
}
}
}
}
#Composable
fun MyComposeItemView(
data: MyItemData,
isSelected: Boolean,
onSelect: (MyItemData) -> Unit = {}) {
// here set item selected state = isSelected maybe change color or something
// assume we have box as single item
Box(Modifier.clickable {
onSelect(data) // cal on click function and pass current data as parameter
})
}

Related

How can I remember the list position in a Compose LazyColumn using Paging 3 LazyPagingItems?

I have a function like:
#Composable
fun LazyElementList(data: Flow<PagingData<Element>>) {
val scrollState = rememberLazyListState()
val elements = data.collectAsLazyPagingItems()
LazyColumn(state = scrollState) {
items(elements) {
DisplayElement(it)
}
}
}
I would like when navigating to another screen and back to maintain the place in the list.
Unexpectedly, the value of scrollState is maintained when visiting child screens. If it wasn't, it should be hoisted, probably into the ViewModel.
In the code in the question scrollState will be reset to the beginning of the list because there are no items in the list on the first composition. You need to wait to display the list until the elements are loaded.
#Composable
fun LazyElementList(data: Flow<PagingData<Element>>) {
val scrollState = rememberLazyListState()
val elements = data.collectAsLazyPagingItems()
if (elements.isLoading) {
DisplayLoadingMessage()
} else {
LazyColumn(state = scrollState) {
items(elements) {
DisplayElement(it)
}
}
}
}
fun LazyPagingItems.isLoading(): Boolean
get() = loadState.refresh is LoadState.Loading

How to save data in a composable function with view model constructor

I have a composable function for the home screen, whenever I navigate to a new composable and come back to this function everything is re created. Do I have to move the view model somewhere higher?
home screen
#Composable
fun HomeScreen(viewModel: NFTViewModel = viewModel()) {
val feedState by viewModel.nftResponse.observeAsState()
if (feedState?.status == Result.Status.SUCCESS) {
val nfts = feedState?.data?.content
LazyColumn {
itemsIndexed(nfts!!) { index, item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
.clickable { },
elevation = 8.dp
) {
Column(
modifier = Modifier.padding(15.dp)
) {
Image(
painter = rememberAsyncImagePainter(
item.firebaseUri
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.height(250.dp)
)
Text(
buildAnnotatedString {
append("Contract Address: ")
withStyle(
style = SpanStyle(fontWeight = FontWeight.W900, color = Color(0xFF4552B8))
) {
append(item.contractAddress)
}
}
)
Text(
buildAnnotatedString {
append("Chain: ")
withStyle(style = SpanStyle(fontWeight = FontWeight.W900)) {
append(item.chain.displayName)
}
append("")
}
)
Text(
buildAnnotatedString {
append("Price: ")
withStyle(style = SpanStyle(fontWeight = FontWeight.W900)) {
append(item.price)
}
append("")
}
)
}
}
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(colorResource(id = R.color.white))
.wrapContentSize(Alignment.Center)
) {
Text(
text = "Feed",
fontWeight = FontWeight.Bold,
color = Color.DarkGray,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
fontSize = 25.sp
)
}
}
}
Navigation
#Composable
fun Navigation(navController: NavHostController) {
NavHost(navController, startDestination = NavigationItem.Home.route) {
composable(NavigationItem.Home.route) {
val vm: NFTViewModel = viewModel()
HomeScreen(vm)
}
composable(NavigationItem.Add.route) {
AddScreen()
}
composable(NavigationItem.Wallets.route) {
WalletsScreen()
}
composable(NavigationItem.Popular.route) {
PopularScreen()
}
composable(NavigationItem.Profile.route) {
ProfileScreen()
}
}
}
It seems like I need to save the view model as a state or something? I seem to be missing something. Basically I dont want to trigger getFeed() everytime the composable function is called, where do I save the data in the compose function, in the view model?
EDIT now calling getFeed from init in the view model
class NFTViewModel : ViewModel() {
private val nftRepository = NFTRepository()
private var successResult: Result<NftResponse>? = null
private var _nftResponse = MutableLiveData<Result<NftResponse>>()
val nftResponse: LiveData<Result<NftResponse>>
get() = _nftResponse
init {
successResult?.let {
_nftResponse.postValue(successResult!!)
} ?: kotlin.run {
getFeed() //this is always called still successResult is always null
}
}
private fun getFeed() {
viewModelScope.launch {
_nftResponse.postValue(Result(Result.Status.LOADING, null, null))
val data = nftRepository.getFeed()
if (data.status == Result.Status.SUCCESS) {
successResult = data
_nftResponse.postValue(Result(Result.Status.SUCCESS, data.data, data.message))
} else {
_nftResponse.postValue(Result(Result.Status.ERROR, null, data.message))
}
}
}
override fun onCleared() {
Timber.tag(Constants.TIMBER).d("onCleared")
super.onCleared()
}
}
However it still seems like a new view model is being created.
It seems like I need to save the view model as a state or something?
You don't have to. ViewModels are already preserved as part of their owner scope. The same ViewModel instance will be returned to you if you retrieve the ViewModels correctly.
I seem to be missing something.
You are initializing a new instance of your NFTViewModel every time the navigation composable recomposes (gets called) instead of retrieving the NFTViewModel from its ViewModelStoreOwner.
You should retrieve ViewModels by calling viewModel() or if you are using Hilt and #HiltViewModel then call hiltViewModel() instead.
No Hilt
val vm: NFTViewModel = viewModel()
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The created ViewModel is associated with the given viewModelStoreOwner and will be retained as long as the owner is alive (e.g. if it is an activity, until it is finished or process is killed).
If using Hilt (i.e. your ViewModel has the #HiltViewModel annotation)
val vm: NFTViewModel = hiltViewModel()
Returns an existing #HiltViewModel-annotated ViewModel or creates a new one scoped to the current navigation graph present on the NavController back stack.
If no navigation graph is currently present then the current scope will be used, usually, a fragment or an activity.
The above will preserve your view model state, however you are still resetting the state inside your composable if your composable exits the composition and then re-enters it at a later time, which happens every time you navigate to a different screen (to a different "screen" composable, if it is just a dialog then the previous composable won't leave the composition, because it will be still displayed in the background).
Due to this part of the code
#Composable
fun HomeScreen(viewModel: NFTViewModel) {
val feedState by viewModel.nftResponse.observeAsState()
// this resets to false when HomeScreen leaves and later
// re-enters the composition
val fetched = remember { mutableStateOf(false) }
if (!fetched.value) {
fetched.value = true
viewModel.getFeed()
}
fetched will always be false when you navigate to (and back to) HomeScreen and thus getFeed() will be called.
If you don't want to call getFeed() when you navigate back to HomeScreen you have to store the fetched value somewhere else, probably inside your NFTViewModel and only reset it to false when you want that getFeed() is called again.

Jetpack Compose: UnsupportedOperationException when adding Entries to MPAndroidCharts dynamically

I try to display live data in an MPAndroidChart hosted in an AndroidView.
I get the graph but an UnsupportedOperationException happens when I call addEntry() to update the graph dynamically. Am I doing something wrong?
You find a demo repo in the comments.
#Composable
fun MyLineChart() {
val mainViewModel = viewModel()
val sensorData = mainViewModel.sensorFlow.collectAsState(SensorModel(0F,0F)).value
AndroidView(modifier = Modifier.fillMaxSize(),
factory = { context ->
val lineChart = LineChart(context)
var entries = listOf(Entry(1f,1f))
val dataSet = LineDataSet(entries, "Label").apply { color = Color.Red.toArgb() }
val lineData = LineData(dataSet)
lineChart.data = lineData
lineChart.invalidate()
lineChart
}){
try {
Log.d("TAG", "MyLineChart: Update --- Current thread id: ${Thread.currentThread()}")
it.data.dataSets[0].addEntry(Entry(sensorData.x, sensorData.y))
it.lineData.notifyDataChanged()
it.notifyDataSetChanged()
it.invalidate()
} catch(ex: Exception) {
Log.d("TAG", "MyLineChart: $ex")
}
}
}
The data is sent to the view via the following ViewModel:
#HiltViewModel
class MainViewModel #Inject constructor(#ApplicationContext var appContext: Context) : ViewModel() {
private var rand: Random = Random(1)
val sensorFlow: Flow<SensorModel> = flow<SensorModel> {
while (true) {
delay(1000L)
Log.d("TAG", "sensorFlow: Current thread id: ${Thread.currentThread()}")
emit(SensorModel(rand.nextFloat(), rand.nextFloat()))
}
}
}
You pass entries to LineDataSet, which is an immutable list.
This library seems to have a pretty bad API, because it doesn't ask for a modifiable list as a parameter, but at the same time it doesn't make it modifiable on its side. This causes you to try to modify the immutable list, which leads to an exception.
Replace
var entries = listOf(Entry(1f,1f))
with
val entries = mutableListOf(Entry(1f,1f))
p.s. I can't advise you another graph library as I haven't worked with any, but I would advise you to look for a library with a better API.
try this code:
// it.data.dataSets[0].addEntry(Entry(sensorData.x, sensorData.y))
val entryList = mutableListOf<Entry>()
entryList.add(Entry(sensorData.x, sensorData.y))
val dataSet = LineDataSet(entryList, "Label").apply {
color = Color.Red.toArgb()
}
it.data = LineData(dataSet)

Monotouch.Dialog Generate from db and retain values

I'm have a settings view where I'm using MT.D to build out my UI. I just got it to read elements from a database to populate the elements in a section.
What I don't know how to do is access each elements properties or values. I want to style the element with a different background color for each item based on it's value in the database. I also want to be able to get the selected value so that I can update it in the db. Here's the rendering of the code that does the UI stuff with MT.D. I can get the values to show up and slide out like their supposed to... but, styling or adding delegates to them to handle clicks I'm lost.
List<StyledStringElement> clientTypes = SettingsController.GetClientTypes ();
public SettingsiPhoneView () : base (new RootElement("Home"), true)
{
Root = new RootElement("Settings") {
new Section ("Types") {
new RootElement ("Types") {
new Section ("Client Types") {
from ct in clientTypes
select (Element) ct
}
},
new StringElement ("Other Types")
}
Here's how I handled it below. Basically you have to create the element in a foreach loop and then populate the delegate with whatever you want to do there. Like so:
public static List<StyledStringElement> GetClientTypesAsElement ()
{
List<ClientType> clientTypes = new List<ClientType> ();
List<StyledStringElement> ctStringElements = new List<StyledStringElement> ();
using (var db = new SQLite.SQLiteConnection(Database.db)) {
var query = db.Table<ClientType> ().Where (ct => ct.IsActive == true && ct.Description != "Default");
foreach (ClientType ct in query)
clientTypes.Add (ct);
}
foreach (ClientType ct in clientTypes) {
// Build RGB values from the hex stored in the db (Hex example : #0E40BF)
UIColor bgColor = UIColor.Clear.FromHexString(ct.Color, 1.0f);
var localRef = ct;
StyledStringElement element = new StyledStringElement(ct.Type, delegate {
ClientTypeView.EditClientTypeView(localRef.Type, localRef.ClientTypeId);
});
element.BackgroundColor = bgColor;
ctStringElements.Add (element);
}
return ctStringElements;
}

Check if textboxes have the same content

I want to check if the textboxes created like this:
(function(arr) {
for (var i = 0; i < arr.length; i++) {
app.add(app.createLabel(arr[i] + " mail"));
app.add(app.createTextBox().setName(arr[i]));
}
})(["first", "second", "third"]);
have the same contents? I was looking for something like getElementByTagname or or getTextboxes, but there are no such functions.
So how to iterate thvrough them and show a label if they are all equal?
To access any widget values you need to add them as a callback element (or a parent panel) to the server handler that will process them. The values of each widget are populated on a parameter passed to the handler function and can be referenced by the widget name (that you already set).
You don't need to setId as suggested on another answer. Unless you want to do something with the widget itself (and not its value). e.g. change its text or hide it, etc.
var textBoxes = ["first", "second", "third"];
function example() {
var app = UiApp.createApplication().setTitle('Test');
var panel = app.createVerticalPanel();
textBoxes.forEach(function(name){
panel.add(app.createLabel(name + " mail"));
panel.add(app.createTextBox().setName(name));
});
panel.add(app.createLabel('Example Label').setId('label').setVisible(false));
var handler = app.createServerHandler('btnHandler').addCallbackElement(panel);
panel.add(app.createButton('Click').addClickHandler(handler));
SpreadsheetApp.getActive().show(app.add(panel));
}
function btnHandler(e) {
var app = UiApp.getActiveApplication(),
allEqual = true;
for( var i = 1; i < textBoxes.length; ++i )
if( e.parameter[textBoxes[i-1]] !== e.parameter[textBoxes[i]] ) {
allEqual = false; break;
}
app.getElementById('label').setVisible(allEqual);
return app;
}
Notice that ServerHandlers do not run instantly, so it may take a few seconds for the label to show or hide after you click the button.
When you create the textboxes, assign each one an id using setId(id).
When you want to obtain their reference later, you can then use getElementById(id).
Here is an example:
function doGet() {
var app = UiApp.createApplication();
app.add(app.createTextBox().setId("tb1").setText("the original text"));
app.add(app.createButton().setText("Change textbox").addClickHandler(app.createServerHandler("myHandler")));
return app;
}
function myHandler() {
var app = UiApp.getActiveApplication();
app.getElementById("tb1").setText("new text: in handler");
return app;
}

Resources