rickclephas / KMP-ObservableViewModel

Library to use AndroidX/Kotlin ViewModels with SwiftUI
MIT License
569 stars 29 forks source link

Binding SwiftUI TextField Values to Complex StateFlow (uiState) #75

Closed Elelan closed 2 months ago

Elelan commented 2 months ago

Hi! I have a question regarding SwiftUI bindings with viewModel.uiState.

In the following LoginViewModel, I've created a uiState object to hold the LoginState. On the Android client, I'm able to use the uiState values and update them without issues. However, I am having difficulty figuring out how to use this viewModel with an iOS SwiftUI client. Specifically, I am unsure how to update the TextField values in SwiftUI and reflect those updates back into our viewModel.uiState.

Is there any way to directly bind the TextField values to properties within uiState in SwiftUI, similar to how we do it in Android? Or is there an alternative approach besides initializing each text value separately in the viewModel instead of using a uiState object?

Here is the code for my LoginViewModel:

import acted.org.collector.ui.screens.auth.login.LoginInputType
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rickclephas.kmp.observableviewmodel.MutableStateFlow
import com.rickclephas.kmp.observableviewmodel.ViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

open class LoginViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(
        viewModelScope = viewModelScope,
        value = LoginState()
    )

    val uiState: StateFlow<LoginState> = _uiState.asStateFlow()

    fun onAction(action: LoginAction) {
        when (action) {
            is LoginAction.OnTextChanged -> handleTextChanged(action.type, action.value)
        }
    }

    private fun handleTextChanged(type: LoginInputType, value: String) {
        _uiState.update {
            when (type) {
                LoginInputType.USERNAME -> it.copy(username = value)
                LoginInputType.PASSWORD -> it.copy(password = value)
            }
        }
    }
}

The LoginState data class:

data class LoginState(
    val isLoading: Boolean = false,
    val username: String = "",
    val password: String = ""
)

And the LoginAction sealed class:

sealed class LoginAction {
    data class OnTextChanged(val type: LoginInputType, val value: String) : LoginAction()
}

In Android, the LoginScreen composable uses the uiState as follows:

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsState()

    Column {
        TextField(
            value = uiState.username,
            onValueChange = { viewModel.onAction(LoginAction.OnTextChanged(LoginInputType.USERNAME, it)) },
        )

        TextField(
            value = uiState.password,
            onValueChange = { viewModel.onAction(LoginAction.OnTextChanged(LoginInputType.PASSWORD, it)) },
        )
    }
}

How do we implement this in SwiftUI? Here is my attempt, which does not work as expected:

import SwiftUI
import KMPObservableViewModelCore
import KMPObservableViewModelSwiftUI
import EcoCollector

struct LoginScreen: View {

    @StateViewModel private var viewModel = LoginViewModel()

    var body: some View {

        var uiState = viewModel.uiState.value

        VStack {

            VStack {

                // MARK: - Username
                TextField("Username", text: $uiState.username)
                    .padding()
                    .background(RoundedRectangle(cornerRadius: 10).stroke(Color.blue, lineWidth: 1))
                    .padding(.horizontal, 50)
                    .padding(.bottom, 20)

                // MARK: - Password
                SecureField("Password", text: $uiState.password)
                    .padding()
                    .background(RoundedRectangle(cornerRadius: 10).stroke(Color.blue, lineWidth: 1))
                    .padding(.horizontal, 50)
                    .padding(.bottom, 20)
            }

        }
    }
}
rickclephas commented 2 months ago

In SwiftUI you would need to manually create a Binding:

TextField("Username", text: Binding {
      viewModel.uiState.value.username
  } set: { value in
      viewModel.onAction(LoginAction.OnTextChanged(LoginInputType.USERNAME, value))
  }
)

Using a generated Binding through the $ only works for mutable properties.

Elelan commented 2 months ago

Thank you for your response. I have another question regarding using KMP-ObservableViewModel in SwiftUI if you don't mind.

How can we pass events from LoginViewModel to LoginScreen.swift?

Here is the LoginEvent sealed class:

sealed class LoginEvent {
    data class LoginSuccess(val userType: UserType) : LoginEvent()
    data class LoginFailed(val error: String) : LoginEvent()
    data class ShowMessage(val message: String) : LoginEvent()
}

In the LoginViewModel, we send events to the UI as follows:

private val _uiEvent: MutableSharedFlow<LoginEvent> = MutableSharedFlow()
val uiEvent = _uiEvent.asSharedFlow()

private suspend fun handleLoginSuccess(userType: UserType) {
    _uiEvent.emit(LoginEvent.LoginSuccess(userType))
}

How can we listen to these events in the LoginScreen.swift and handle them appropriately? Specifically, how can we observe these uiEvent flows in SwiftUI and react to them?

If we can't use SharedFlow, is there any alternatives for doing this?

Thank you for your assistance!

rickclephas commented 2 months ago

There is no built-in support for SharedFlows. Though depending on your goal there are a couple of ways you can consume such a Flow from Swift. Could you share how you are consuming those events in Android?

Elelan commented 2 months ago

In android, I do the following;

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel(),
    invokeUiEvent: (UiEvent) -> Unit
) {

    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    LaunchedEffect(key1 = Unit) {
        viewModel.uiEvent.collectLatest { event ->
            when (event) {
                is LoginEvent.LoginFailed -> invokeUiEvent(UiEvent.ShowSnackBar(event.error))
                is LoginEvent.LoginSuccess -> {
                    invokeUiEvent(
                        UiEvent.Navigation.Navigate(
                            AppScreen.Home.Dashboard.withArgs(ArgParams.USER_TYPE to event.userType),
                            AppNavController.ROOT
                        )
                    )
                }
                is LoginEvent.ShowMessage -> invokeUiEvent(UiEvent.ShowSnackBar(event.message))
            }
        }
    }

    LoginScreenContent(
        state = uiState,
        invokeUiEvent = invokeUiEvent,
        onAction = viewModel::onAction
    )
}

Could you suggest a way to consume these event flows in SwiftUI?

I can use kotlinx.coroutines.channels for LoginEvent as well if it's possible to collect it in SwiftUI.

private val _uiEvent = Channel<LoginEvent>()
val uiEvent = _uiEvent.receiveAsFlow()
rickclephas commented 2 months ago

Thanks. If you are looking for a similar approach in SwiftUI then you might want to take a look at onReceive(_:perform:). It will perform an action when a publisher emits a value.

For that you would need to convert your SharedFlow to a Combine Publisher which can be done with KMP-NativeCoroutines.

I guess it would be easiest to create a Swift subclass of the ViewModel, creating and storing the publisher in the init.