I am adding a ComposeView to my XML layout. The ComposeView is a widget that will stay hidden until a button is clicked (button is in XML).
I have tried a number of things, but the way the app is setup my ComposeView widget does not recompose after the the button click.
Code
DetailsWidget is where the onClickListener is set for each widget
class DetailsWidget : ListingDetailsWidget {
...
override fun buildView(inflater: LayoutInflater) : View? {
...
val expandButton = baseView.findViewById<View>(R.id.btn_expand)
val buttonContainer = baseView.findViewById<View>(R.id.expand_button_container)
val tableContainerComposeview = baseView.findViewById<View>(R.id.table_container_compose_view) <- I added this line to existing code
tableView.post {
if (!tableView.isExpanded) {
buttonContainer.visibility = View.VISIBLE
expandButton.setOnClickListener {
tableView.isExpanded = true
buttonContainer.visibility = View.GONE
DetailsWidgetViewModel().onTableViewExpanded() <- I added this line to existing code
}
}
}
return baseView
}
}
DetailsWidgetViewModel
class DetailsWidgetViewModel : ViewModel() {
private val _showDetailsWidget = MutableStateFlow(false)
val showDetailsWidget: StateFlow<Boolean> = _showDetailsWidget.asStateFlow()
fun onTableViewExpanded() {
_showDetailsWidget.value = true
}
}
JetpackComposeWidget
#Parcelize
#Serializable
data class JetpackComposeWidgetData(
...
) : ListingDetailsWidget() {
override fun buildView(inflater: LayoutInflater): View {
val binding = ViewDetailWidgetDetailsBinding.inflate(inflater, null, false)
val view = binding.root
binding.tableContainerComposeView.setContent {
JetpackComposeWidget(data = this)
}
return view
}
}
#Composable
fun JetpackComposeWidget(data: JetpackComposeWidgetData) {
val showDetailsWidgetState: Boolean by DetailsWidgetViewModel().showDetailsWidget.collectAsState()
if (showDetailsWidgetState) {
LdpWidgetTheme {
Surface(
color = MaterialTheme.colors.surface,
modifier = Modifier.clickable { DetailsWidgetViewModel().onTableViewExpanded() }
) {
Column(
Modifier
.padding(8.dp),
) {
data.title?.let {
Text(
text = it,
style = MaterialTheme.typography.h4,
)
}
data.sections.mapIndexed { index, section -> Section(section, index == 0) }
}
}
}
}
}
Is there a better way to show / hide a ComposeView in XML?
What I've tried:
Add to ComposeView XML android:onClick='onTableViewExpanded'
Add to ComposeView XML android:visibility:"gone" then setting the ComposeView to View.VISIBLE in the setOnClickListener code block
Call an event listener on button click
Recompose on button click using _showDetailsWidget.copy(value = true)
Live Data and Mutable State instead of MutableStateFlow
Related
The composable host an AndroidView that is a FragmentContainerView which has multiple child Fragments on back press of the FragmentContainerView we want to close the #Game composable.
#Composable
fun Game(data: Bundle? = null) {
val user = GamingHubAuthManager.getUser().observeAsState()
AndroidViewBinding(EntryPointBinding::inflate) {
// val myFragment = fragmentGameContainerView.getFragment<FeatureCardFragment>
}
}
You can control if your Game composable is part of the composition from its parent composable with some state and a simple if statement.
To change the state on back press you can use the BackHandler composable.
A working example:
import androidx.compose.runtime.*
#Composable
fun GameParent() {
var gameIsActive by remember { mutableStateOf(true) } // or false for the starting state
BackHandler(enabled = gameIsActive) {
gameIsActive = false
}
if (gameIsActive) {
Game()
} else {
Button(
onClick = { gameIsActive = true }
) {
Text("Start game")
}
}
}
#Composable
fun Game(data: Bundle? = null) {
val user = GamingHubAuthManager.getUser().observeAsState()
AndroidViewBinding(EntryPointBinding::inflate) {
// val myFragment = fragmentGameContainerView.getFragment<FeatureCardFragment>
}
}
If you will have to close the game from some other handler(s) from inside the Game composable then taking this approach might be better
import androidx.compose.runtime.*
#Composable
fun GameParent() {
var gameIsActive by remember { mutableStateOf(true) } // or false for the starting state
if (gameIsActive) {
Game(onClose = { gameIsActive = false })
} else {
Button(
onClick = { gameIsActive = true }
) {
Text("Start game")
}
}
}
#Composable
fun Game(data: Bundle? = null, onClose: () -> Unit) {
BackHandler(enabled = true) {
// this way you can even pass some result back if you parametrize
// this callback, for example won/lost/draw/quit.
onClose()
}
val user = GamingHubAuthManager.getUser().observeAsState()
AndroidViewBinding(EntryPointBinding::inflate) {
// val myFragment = fragmentGameContainerView.getFragment<FeatureCardFragment>
// call onClose() from some other handler
}
}
In the following viewModel code I am generating a list of items from graphQl server
private val _balloonsStatus =
MutableStateFlow<Status<List<BalloonsQuery.Edge>?>>(Status.Loading())
val balloonsStatus get() = _balloonsStatus
private val _endCursor = MutableStateFlow<String?>(null)
val endCursor get() = _endCursor
init {
loadBalloons(null)
}
fun loadBalloons(cursor: String?) {
viewModelScope.launch {
val data = repo.getBalloonsFromServer(cursor)
if (data.errors == null) {
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
_endCursor.value = data.data?.balloons?.pageInfo?.endCursor
} else {
_balloonsStatus.value = Status.Error(data.errors!![0].message)
_endCursor.value = null
}
}
}
and in the composable function I am getting this data by following this code:
#Composable
fun BalloonsScreen(
navHostController: NavHostController? = null,
viewModel: SharedBalloonViewModel
) {
val endCursor by viewModel.endCursor.collectAsState()
val balloons by viewModel.balloonsStatus.collectAsState()
AssignmentTheme {
Column(modifier = Modifier.fillMaxSize()) {
when (balloons) {
is Status.Error -> {
Log.i("Reyjohn", balloons.message!!)
}
is Status.Loading -> {
Log.i("Reyjohn", "loading..")
}
is Status.Success -> {
BalloonList(edgeList = balloons.data!!, navHostController = navHostController)
}
}
Spacer(modifier = Modifier.weight(1f))
Button(onClick = { viewModel.loadBalloons(endCursor) }) {
Text(text = "Load More")
}
}
}
}
#Composable
fun BalloonList(
edgeList: List<BalloonsQuery.Edge>,
navHostController: NavHostController? = null,
) {
LazyColumn {
items(items = edgeList) { edge ->
UserRow(edge.node, navHostController)
}
}
}
but the problem is every time I click on Load More button it regenerates the view and displays a new set of list, but I want to append the list at the end of the previous list. As far I understand that the list is regenerated as the flow I am listening to is doing the work behind this, but I am stuck here to get a workaround about how to achieve my target here, a kind hearted help would be much appreciated!
You can create a private list in ViewModel that adds List<BalloonsQuery.Edge>?>
and instead of
_balloonsStatus.value = Status.Success(data.data?.balloons?.edges)
you can do something like
_balloonsStatus.value = Status.Success(myLiast.addAll(
data.data?.balloons?.edges))
should update Compose with the latest data appended to existing one
I'm building a jetpack compose app and I want my view model to tell my compose function to display a snack bar by sending it an event. I have read multiple blog posts about the Single Live Event case with Kotlin and I tried to implement it with Compose and Kotlin Flow. I managed to send the event from the view model (I see it in the logs) but I don't know how to receive it in the composable function. Can someone help me figure it out please? Here is my implementation.
class HomeViewModel() : ViewModel() {
sealed class Event {
object ShowSheet : Event()
object HideSheet : Event()
data class ShowSnackBar(val text: String) : Event()
}
private val eventChannel = Channel<Event>(Channel.BUFFERED)
val eventsFlow: Flow<Event> = eventChannel.receiveAsFlow()
fun showSnackbar() {
Timber.d("Show snackbar button pressed")
viewModelScope.launch {
eventChannel.send(Event.ShowSnackBar("SnackBar"))
}
}
}
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val context = LocalContext.current
val scaffoldState = rememberScaffoldState()
val sheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
val lifecycleOwner = LocalLifecycleOwner.current
val eventsFlowLifecycleAware = remember(viewModel.eventsFlow, lifecycleOwner) {
eventsFlow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
LaunchedEffect(sheetState, scaffoldState.snackbarHostState) {
eventsFlowLifecycleAware.onEach {
when (it) {
HomeViewModel.Event.ShowSheet -> {
Timber.d("Show sheet event received")
sheetState.show()
}
HomeViewModel.Event.HideSheet -> {
Timber.d("Hide sheet event received")
sheetState.hide()
}
is HomeViewModel.Event.ShowSnackBar -> {
Timber.d("Show snack bar received")
scaffoldState.snackbarHostState.showSnackbar(
context.getString(it.resId)
)
}
}
}
}
ModalBottomSheetLayout(
sheetState = sheetState,
sheetContent = {
Text("Sheet")
}
) {
Button(
onClick = {
viewModel.showSheet()
}
) {
Text("Show SnackBar")
}
}
}
For reference, I've used these blog posts:
Android SingleLiveEvent Redux with Kotlin Flow
A safer way to collect flows from Android UIs
Ok, I was using the wrong approach, I must not send events, I must update the view state and check if I should show the snackbar when recomposing. Something like that:
You store the SnackBar state in the view model
class HomeViewModel: ViewModel() {
var isSnackBarShowing: Boolean by mutableStateOf(false)
private set
private fun showSnackBar() {
isSnackBarShowing = true
}
fun dismissSnackBar() {
isSnackBarShowing = false
}
}
And in the view you use LaunchedEffect to check if you should show the snackbar when recomposing the view
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
) {
val onDismissSnackBarState by rememberUpdatedState(newValue = onDismissSnackBar)
if (isSnackBarShowing) {
val snackBarMessage = "Message"
LaunchedEffect(isSnackBarShowing) {
try {
when (scaffoldState.snackbarHostState.showSnackbar(
snackBarMessage,
)) {
SnackbarResult.Dismissed -> {
}
}
} finally {
onDismissSnackBarState()
}
}
}
Row() {
Text(text = "Hello")
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
viewModel.showSnackBar()
}
) {
Text(text = "Show SnackBar")
}
}
}
I think you have to collect eventsFlowLifecycleAware as a state to trigger a Composable correctly.
Try removing the LaunchedEffect block, and using it like this:
val event by eventsFlowLifecycleAware.collectAsState(null)
when (event) {
is HomeViewModel.Event.ShowSnackBar -> {
// Do stuff
}
}
I am currently trying to write an App for my thesis and currently, I am looking into different approaches. Since I really like Flutter and the Thesis requires me to use Java/Kotlin I would like to use Jetpack compose.
Currently, I am stuck trying to update ListElements.
I want to have a List that shows Experiments and their state/result. Once I hit the Button I want the experiments to run and after they are done update their state. Currently, the run Method does nothing besides setting the state to success.
The problem is I don't know how to trigger a recompose from the viewModel of the ExperimentRow once an experiment updates its state.
ExperimentsActivity:
class ExperimentsActivity : AppCompatActivity() {
val exViewModel by viewModels<ExperimentViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//For now this is just Dummy Data and will be replaced
exViewModel.experiments += listOf(
Experiment("Test1", exViewModel::experimentStateChanged),
Experiment("Strongbox", exViewModel::experimentStateChanged)
)
setContent {
TpmTheme {
// A surface container using the 'background' color from the theme
Surface {
ExperimentScreen(
exViewModel.experiments,
exViewModel::startTests
)
}
}
}
}
}
ExperimentViewModel:
class ExperimentViewModel : ViewModel() {
var experiments by mutableStateOf(listOf<Experiment>())
fun startTests() {
for (exp in experiments) {
exp.run()
}
}
fun experimentStateChanged(experiment: Experiment) {
Log.i("ViewModel", "Changed expState of ${experiment.name} to ${experiment.state}")
// HOW DO I TRIGGER A RECOMPOSE OF THE EXPERIMENTROW FOR THE experiment????
//experiments = experiments.toMutableList().also { it.plus(experiment) }
Log.i("Vi", "Size of Expirments: ${experiments.size}")
}
}
ExperimentScreen:
#Composable
fun ExperimentScreen(
experiments: List<Experiment>,
onStartExperiments: () -> Unit
) {
Column {
LazyColumnFor(
items = experiments,
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(top = 8.dp),
) { ep ->
ExperimentRow(
experiment = ep,
modifier = Modifier.fillParentMaxWidth(),
)
}
Button(
onClick = { onStartExperiments() },
modifier = Modifier.padding(16.dp).fillMaxWidth(),
) {
Text("Run Tests")
}
}
}
#Composable
fun ExperimentRow(experiment: Experiment, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(experiment.name)
Icon(
asset = experiment.state.vAsset,
)
}
Experiment:
class Experiment(val name: String, val onStateChanged: (Experiment) -> Unit) {
var state: ExperimentState = ExperimentState.DEFAULT
set(value) {
field = value
onStateChanged(this)
}
fun run() {
state = ExperimentState.SUCCESS;
}
}
enum class ExperimentState(val vAsset: VectorAsset) {
DEFAULT(Icons.Default.Info),
RUNNING(Icons.Default.Refresh),
SUCCESS(Icons.Default.Done),
FAILED(Icons.Default.Warning),
}
There's a few ways to address this but key thing is that you need to add a copy of element (with state changed) to experiments to trigger the recomposition.
One possible example would be
data class Experiment(val name: String, val state: ExperimentState, val onStateChanged: (Experiment) -> Unit) {
fun run() {
onStateChanged(this.copy(state = ExperimentState.SUCCESS))
}
}
and then
fun experimentStateChanged(experiment: Experiment) {
val index = experiments.toMutableList().indexOfFirst { it.name == experiment.name }
experiments = experiments.toMutableList().also {
it[index] = experiment
}
}
though I suspect there's probably cleaner way of doing this.
I have the following issue where the state change on the counter variable does not trigger recomposition of the Text component. Any ideas of what might be the problem???
#Model
class CounterState(var counter:Int = 0)
#Composable
fun CounterView(counterState: CounterState){
Column {
Text(text= "${counterState.counter}",
style=(+typography()).body1)
Button(text ="Increment",
onClick = {counterState.counter++},
style = ContainedButtonStyle())
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
app {
CounterView(CounterState())
}
}
}
var counter by +state { 0 }
Column {
Text(
text = "$counter",
style = (+typography()).body1
)
Button(
text = "Increment",
onClick = { counter++ },
style = ContainedButtonStyle()
)
}