multi-dimensional state in jetpack compose - android-jetpack-compose

How can I make multidimensional state in compose that also effects the source the state came from?
I tried to ask this question without code previously and it wasn't clear. This is a little more code than necessary, but I will be able to communicate my problem
data class Record(
var duration: Int,
val timeStamp: Instant = Clock.System.now(),
var description: String? = null
)
data class Task(
var name: String,
var defaultAmount: Int,
val completions: MutableList<Record> = mutableListOf()
) {
fun createRecord(description: String? = null, duration: Int = defaultAmount): Record {
val record = Record(duration, description = description)
completions.add(record)
return record
}
}
class SourceOfTruth(private val thisSavesMeTyping: MutableList<Task> = mutableListOf()) :
MutableCollection<Task> by thisSavesMeTyping {
fun print() = thisSavesMeTyping.forEach { Log.d("ugh", it.toString()) }
}
class MyModel : ViewModel() {
val source: SourceOfTruth =
SourceOfTruth().apply {
add(
Task("exercise", 30).apply {
createRecord("push ups")
createRecord("sit ups")
})
add(Task("dishes", 10))
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val myModel: MyModel by viewModels()
val tasks = myModel.source.toMutableStateList()
Column {
tasks.forEach { task ->
Row {
Text(task.name)
task.completions.forEach { Text(it.timeStamp.toString().dropLast(15)) }
Button(
onClick = {
task.createRecord("dummy")
myModel.source.print()
}) { Text("now") }
}
}
Button(
onClick = {
tasks.add(Task("dummy task", 90))
myModel.source.print()
}) { Text("new Task") }
}
}
}
}
Pushing the now button updates the source in the viewmodel, but doesn't trigger recomposition. Pushing the add task button triggers recomposition, but doesn't add a task to the source of truth.
Any suggestions on how I can fix those?
If you read all that, Thank you very much. I wish I could buy you a coffee or beer.

Related

Android, Jetpack Compose - Composable function not getting refreshed

I am new to Android Jetpack compose.
All i want to do is to update data in UI when it gets from API.
Here is my Composable function:
#Composable
fun getCurrentWeather(lat : Double, long : Double, nc : NavHostController,
vm: CurrentConditionsViewModel = hiltViewModel()) {
vm.getWeather(lat = lat, long = long)
var check by remember { mutableStateOf("") }
var text by remember { mutableStateOf("") }
val state = vm._state.value
when (state) {
is Resource.Loading -> {
CircularProgressIndicator(
modifier = Modifier
.fillMaxSize()
.wrapContentSize(align = Alignment.Center)
)
check = "hi"
}
is Resource.Success -> {
for (i in state.data!!.weather) {
print("called")
}
state.data.name.let {
text = it.toString()
}
}
is Resource.Error -> {
Log.d("StateError", state.message.toString())
}
}
}
And here is my ViewModel:
#HiltViewModel
class CurrentConditionsViewModel #Inject constructor(private val weatherRepository: WeatherRepositoryImpl) :
ViewModel() {
val _state: MutableState<Resource<CurrentConditionsDataModel>?> = mutableStateOf(null)
fun getWeather(lat : Double, long : Double) {
viewModelScope.launch {
weatherRepository.getWeather(latt = lat, longg = long).collect {
_state.value = it
}
}
}
}
Everything is going fine but the only problem is that Composable function is not getting called state variable is updated in view model. I have tried using other variables too such as check and text, yet Composable is only getting called once.
What am I doing wrong? I there anything I have missed?
Thanks in advance.

How can we test Jetpack Compose callback function on a click action?

The problem is:
We have a test case where we expect that a callback function was called in response to a click action in our Composable. How can we handle this test correctly?
You can try something like this:
#Test
fun test() {
var clicked = false
testRule.setContent {
YourComposable(
onClick = { clicked = true },
}
}
testRule.onNodeWithTag("YourButton")
.performClick()
assertTrue(clicked)
}
I'm not sure if you understand state hoisting,reference please try simple example:
#Composable
fun ButtonState(modifier : Modifier = Modifier,state : (flag : Boolean)->Unit) {
var isFlag by remember{ mutableStateOf(false)}
Button(onClick = {
isFlag = !isFlag
state(isFlag)
},modifier) {
Text(text = "text")
}
}
setContent {
var flag by remember { mutableStateOf(false) }
Column {
ButtonState {
flag = it
}
Text(text = "$flag")
}
}
#get:Rule
val composeTestRule = createComposeRule()
#Test
fun `should invoke callback on button click`() {
val callback : () -> Unit = mockk()
composeTestRule.setContent {
YourComposable(
onClick = callback,
)
}
composeTestRule.onNodeWithTag("BUTTON_TAG").performClick()
verify { callback() }
}

Jetpack Compose - Detect when LazyColumn's scroll position is at the first index

I want to have a Scroll back to top button for my LazyColumn. I successfully made the button work. But I want it to not be visible if I'm already at the top of the LazyColumn. How could I achieve this?
LazyColumn has state property, and if you pass your custom value instead of the default one, you can react on the state changes.
To prevent redundant recompositions, in such cases derivedStateOf should be used: it'll trigger recomposition only when the produced result, based on other state variables, is changed:
Box {
val state = rememberLazyListState()
LazyColumn(state = state) {
// ...
}
val firstItemVisible by remember {
derivedStateOf {
state.firstVisibleItemIndex == 0
}
}
if (!firstItemVisible) {
Button(onClick = { /*TODO*/ }) {
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(intent)
setContent {
val scrollState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
if (scrollState.firstVisibleItemIndex > 0) {
Button(onClick = {
coroutineScope.launch {
scrollState.scrollToItem(0)
}
}, enabled = scrollState.firstVisibleItemIndex > 0) {
Text("Scroll to top")
}
}
LazyColumn(modifier = Modifier.fillMaxSize(), state = scrollState) {
items(MutableList(100) { it }) { i ->
Text(i.toString())
}
}
}
}
}
}

Perform action when user change Picker value

I have a piece of sample code that shows picker. It is a simplified version of project that I'm working on. I have a view model that can be updated externally (via bluetooth - in example it's simulated) and by user using Picker. I would like to perform an action (for example an update) when user changes the value. I used onChange event on binding that is set on Picker and it works but the problem is that it also is called when value is changed externally which I don't want. Does anyone knows how to make it work as I expect?
enum Type {
case Type1, Type2
}
class ViewModel: ObservableObject {
#Published var type = Type.Type1
init() {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
self.type = Type.Type2
}
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
VStack {
Row(type: $viewModel.type) {
self.update()
}
}
}
func update() {
// some update logic that should be called only when user changed value
print("update")
}
}
struct Row: View {
#Binding var type: Type
var action: () -> Void
var body: some View {
Picker("Type", selection: $type) {
Text("Type1").tag(Type.Type1)
Text("Type2").tag(Type.Type2)
}
.onChange(of: type, perform: { newType in
print("changed: \(newType)")
action()
})
}
}
EDIT:
I found a solution but I'm not sure if it's good one. I had to use custom binding like this:
struct Row: View {
#Binding var type: Type
var action: () -> Void
var body: some View {
let binding = Binding(
get: { self.type },
set: { self.type = $0
action()
}
)
return Picker("Type", selection: binding) {
Text("Type1").tag(Type.Type1)
Text("Type2").tag(Type.Type2)
}
}
}

SwiftUI MVVM AnyViewModel not propagating state changes

I'm trying to implement MVVM in my SwiftUI app in a way that decouples the view from the view model itself. In my research I came across this article outlining one strategy: https://quickbirdstudios.com/blog/swiftui-architecture-redux-mvvm/
Here's a summary of how it works:
// ViewModel.swift
protocol ViewModel: ObservableObject where ObjectWillChangePublisher.Output == Void {
associatedtype State
associatedtype Event
var state: State { get }
func trigger(_ event: Event)
}
// AnyViewModel.swift
final class AnyViewModel<State, Event>: ObservableObject {
private let wrappedObjectWillChange: () -> AnyPublisher<Void, Never>
private let wrappedState: () -> State
private let wrappedTrigger: (Event) -> Void
var objectWillChange: some Publisher {
wrappedObjectWillChange()
}
var state: State {
wrappedState()
}
func trigger(_ input: Event) {
wrappedTrigger(input)
}
init<V: ViewModel>(_ viewModel: V) where V.State == State, V.Event == Event {
self.wrappedObjectWillChange = { viewModel.objectWillChange.eraseToAnyPublisher() }
self.wrappedState = { viewModel.state }
self.wrappedTrigger = viewModel.trigger
}
}
// MyView.swift
extension MyView {
enum Event {
case onAppear
}
enum ViewState {
case loading
case details(Details)
}
struct Details {
let title: String
let detail: String
}
}
struct MyView: View {
#ObservedObject var viewModel: AnyViewModel<ViewState, Event>
var body: some View { ... }
}
// ConcreteViewModel.swift
class ConcreteViewModel: ViewModel {
#Published var state: MyView.ViewState = .loading
func trigger(_ event: MyView.Event) {
...
state = .details(...) // This gets called by my app and the state is updated.
...
}
}
// Constructing MyView
let view = MyView(viewModel: AnyViewModel(ConcreteViewModel))
This succeeds in separating the view from the view model (using AnyViewModel as a wrapper), but the issue is updates to the state property in ConcreteViewModel are not reflected in MyView.
My suspicion is that the problem lies in AnyViewModel and the wrappedObjectWillChange closure, but I am having difficulty debugging it. Do I need to do something with the objectWillChange publisher explicitly, or should #Published handle it automatically?
Any help is much appreciated.
I believe the var objectWillChange: some Publisher is not being resolved correctly by SwiftUI type checker. Setting it to the matching type var objectWillChange: AnyPublisher<Void, Never> should fix the bug.
See: https://gist.github.com/LizzieStudeneer/c3469eb465e2f88bcb8225df29fbbb77

Resources