icerockdev / moko-mvvm

Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform development
https://moko.icerock.dev/
Apache License 2.0
1.03k stars 95 forks source link

Call objectWillChange.send() into Binding setter when use viewModel.binging #221

Open PGochachko opened 1 year ago

PGochachko commented 1 year ago

Hello!

I noticed strange behavior when using the "binding" function of the ViewModel in swift (iOS). This happens when masking a value in a TextField.

Code sample:

SwiftUIView.swift

import SwiftUI
import MultiPlatformLibrary

struct SwiftUIView: View {
    @StateObject
    private var viewModel: ExampleViewModel = ExampleViewModel()

    var body: some View {
        TextField("Input text",
                  text: viewModel.binding(\.text,
                                           equals: { $0 == $1 },
                                           getMapper: { viewModel.mask(value: $0 as String) },
                                           setMapper: { viewModel.unmask(value: $0) as NSString }
                                         )
        )
        .padding()
    }
}

ExampleViewModel.kt

import dev.icerock.moko.mvvm.flow.CMutableStateFlow
import dev.icerock.moko.mvvm.flow.cMutableStateFlow
import dev.icerock.moko.mvvm.viewmodel.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow

class ExampleViewModel: ViewModel() {
    companion object {
        const val MASK_CHAR = '#'
    }

    val text: CMutableStateFlow<String> = MutableStateFlow("")
        .cMutableStateFlow()

    fun mask(value: String): String {
        val mask = "##.##.##"
        val stringBuilder = StringBuilder(mask.length)
        var vIndex = 0

        for (mc in mask) {
            val c = value.getOrNull(vIndex) ?: break
            when (mc) {
                MASK_CHAR, c -> {
                    stringBuilder.append(c)
                    vIndex++
                }
                else -> stringBuilder.append(mc)
            }
        }

        return stringBuilder.toString()
    }

    fun unmask(value: String): String {
        val mask = "##.##.##"
        val stringBuilder = StringBuilder(mask.count { it == MASK_CHAR })

        for (i in value.take(mask.length).indices) {
            val c = value[i]
            val mc = mask.getOrNull(i)

            if (c != mc) {
                stringBuilder.append(c)
            }
        }

        return stringBuilder.toString()
    }
}

When I enter "112233" I get the result "11.22.33". But as soon as I double click on "3" again, I get the result "11.22.333" in ui, but the variable has the correct value "11.22.33".

This problem is related to the fact that stateFlow.value does not change if this value is already set to value, and self.objectWillChange.send() is called only when stateFlow.value is updated inside the "binding" function.

The simplest fix that came to mind based on your source code is this: Append self.objectWillChange.send() on set into Binding

func binding<T, R>(
    _ flowKey: KeyPath<Self, CMutableStateFlow<T>>,
    equals: @escaping (T?, T?) -> Bool,
    getMapper: @escaping (T) -> R,
    setMapper: @escaping (R) -> T
) -> Binding<R> {
    let stateFlow: CMutableStateFlow<T> = self[keyPath: flowKey]
    var lastValue: T? = stateFlow.value

    var disposable: DisposableHandle? = nil

    disposable = stateFlow.subscribe(onCollect: { value in
        if !equals(lastValue, value) {
            lastValue = value
            self.objectWillChange.send()
            disposable?.dispose()
        }
    })

    return Binding(
        get: { getMapper(stateFlow.value!) },
        set: {
            stateFlow.value = setMapper($0)
            self.objectWillChange.send()
        }
    )
}

If you see fit, please add to the library.

Thanks!

PGochachko commented 1 year ago

I'm testing this fix on another example - it's bad fix :) It may be necessary to abandon this situation, or think about another solution...