Search code examples
androidkotlinstateandroid-custom-view

Save and Restore state for view with custom attributes


I have a custom view aiming to switch between two currencies. The idea is simple, a main text field with a currency abbreviation, a button to switch and a chip with the converted amount.

In the custom view, I have another custom view which contains an EditText and a TextView which is what I use for the main amount display. Here is a small diagram of the 'layout' of the views:

Custom View layout

As you can see, there isn't a huge amount going on here in terms of components. However, internally, I am doing a certain amount of calculations and logic.

In the AmountEditText view, I have a single custom attribute which sets the value of the TextView component and the EditText simply handles user input (limited to digits) and has a backing property converting the text entered to a Float.

In the SwitchableTextField (which extends a ConstraintLayout), I have more custom attributes being the following:

  • defaultCurrency: String <= Used to set the content of the TextView in the AmountEditText
  • conversionCurrency: String <= Used alongside the converted value from the EditText in the AmountEditText to display in the Chip
  • conversionDirection: Boolean <= Used to define which way the conversion is going

The code I am having issues with is in the SwitchableTextField and is the following (Some parts have been removed for simplicity's sake):

class SwitchableTextField(context: Context, attrs: AttributeSet) :
    ConstraintLayout(context, attrs) {

    var defaultCurrency: String = CURRENCY_EUR
        set(value) {
            if (field != value) {
                field = value
                binding.mainAmountField.currency = value
            }
        }

    var mainAmount: Float = 0f
        get() = binding.mainAmountField.tokenAmount.toString().parseDecimalNumber()
        set(value) {
            if (field != value) {
                field = value
                convertAmount(value)
            }
        }

    var conversionCurrency: String = CURRENCY_SYMBOL
        set(value) {
            if (field != value) {
                field = value
            }
        }

    var convertedAmount: Float = 0f

    var assetAmount: Float = 0f

    var conversionDirection: Boolean = true

    var listener: AmountInputListener? = null

    init {
        isSaveEnabled = true
        applyUserAttributes(context, attrs)
        binding.mainAmountField.currency = defaultCurrency
        binding.mainAmountField.addOnTextChangedListener { text ->
            mainAmount = text.parseDecimalNumber()
        }
        binding.currencySwitchButton.setOnClickListener {
            switchConversionDirection()
        }
    }

    private fun applyUserAttributes(context: Context, attrs: AttributeSet?) {
        val typedArray = context.theme.obtainStyledAttributes(
            attrs,
            R.styleable.SwitchableCurrencyField,
            0,
            0
        )

        defaultCurrency = typedArray.getString(R.styleable.SwitchableCurrencyField_defaultCurrency)
            ?: defaultCurrency

        conversionCurrency =
            typedArray.getString(R.styleable.SwitchableCurrencyField_conversionCurrency)
                ?: conversionCurrency

        conversionDirection =
            typedArray.getBoolean(R.styleable.SwitchableCurrencyField_convertFromAsset, true)

        typedArray.recycle()
    }

    // Some internal code

    override fun onSaveInstanceState(): Parcelable? {
        return SavedState(super.onSaveInstanceState()).apply {
            childrenStates = saveChildViewStates()
        }
    }

    override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>?) {
        dispatchFreezeSelfOnly(container)
    }

    internal class SavedState : AbsSavedState {
        internal var childrenStates: SparseArray<Parcelable>? = null

        constructor(superState: Parcelable?) : super(superState)

        @Suppress("UNCHECKED_CAST")
        constructor(source: Parcel) : super(source) {
            childrenStates = source.readSparseArray(javaClass.classLoader)
        }

        @Suppress("UNCHECKED_CAST")
        override fun writeToParcel(out: Parcel, flags: Int) {
            super.writeToParcel(out, flags)
            out.writeSparseArray(childrenStates as SparseArray<Any>)
        }

        companion object {
            @Suppress("UNUSED")
            @JvmField
            val CREATOR = object : Parcelable.Creator<SavedState> {
                override fun createFromParcel(source: Parcel) = SavedState(source)
                override fun newArray(size: Int): Array<SavedState?> = arrayOfNulls(size)
            }
        }
    }

    public override fun onRestoreInstanceState(state: Parcelable?) {
        when (state) {
            is SavedState -> {
                super.onRestoreInstanceState(state.superState)
                state.childrenStates?.let { restoreChildViewStates(it) }
            }
            else -> super.onRestoreInstanceState(state)
        }
    }

    override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>?) {
        dispatchThawSelfOnly(container)
    }
}

Assume I have the following attributes set for the view:

  • defaultCurrency = "EUR"
  • conversionCurrency = "USD"
  • conversionDirection = true

Upon entering "10" in the EditText, the view displays the following:

 10 EUR
// Button
11.87 USD

Pressing the ImageButton works as intended and both the values and currency abbreviations switch. However, the issue I have is that when I navigate to the next fragment after having switched (meaning USD is in the AmountEditText and not EUR anymore), returning to the fragment with this view keeps the amount in the EditText but returns the defined attributes to the values they had originally: defaultCurrency = "EUR", conversionCurrency = "USD", conversionDirection = true

The workflow I expect to happen is the following:

 10 EUR                             11.87 USD                                11.87 USD
// Button -- Switching currency --> // Button -- Next fragment then back --> // Button
11.87 USD                            10 EUR                                   10 EUR

What actually happens is:

 10 EUR                             11.87 USD                                11.87 EUR
// Button -- Switching currency --> // Button -- Next fragment then back --> // Button
11.87 USD                            10 EUR                                  14.09 USD

Meaning the text entered in the EditText stays (which is good) but the rest is reset and hence the conversion is incorrect.

I suspect the issue comes from the custom attributes being read from the TypedArray and resetting the data restored but I have no idea how I should correct this.


Solution

  • The solution is rather simple but took me long enough to find.

    Removing the if (field != value) from the properties and correctly implementing the SavedState with internal var ... for each of the properties that require saving does the trick.

    NOTE : The readBoolean and writeBoolean functions for a Parcel exist only from API 29 so using another type was required (in my case a custom enum for the attributes resulting in an int).

    Here is the resulting onSaveInstanceState function from which you can deduce the remaining code in the other functions:

    override fun onSaveInstanceState(): Parcelable? {
        return SavedState(super.onSaveInstanceState()).apply {
            childrenStates = saveChildViewStates()
            defaultCurrency = this@SwitchableCurrencyField.defaultCurrency
            conversionCurrency = this@SwitchableCurrencyField.conversionCurrency
            conversionDirection = this@SwitchableCurrencyField.conversionDirection
            mainAmount = this@SwitchableCurrencyField.mainAmount
        }
    }