icerockdev / moko-mvvm

Model-View-ViewModel architecture components for mobile (android & ios) Kotlin Multiplatform development

Home Page:https://moko.icerock.dev/

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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

PGochachko opened this issue · comments

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!

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...