1

I want to display a custom dialog on my app that has an input field. I extended the DialogFragment class and setup the layout I wanted. The problem I'm having is that I need a way to retrieve the data from the Input if the user hits save.

First attempt: retrieving via onAttachFragment

I first tried to create an interface with a single function that returns that value, made my fragment (the one that is going to call the Custom Dialog) implement it. Them, I overrode the onAttachFragment on my Custom Dialog like this:

class CustomDialogFragment private constructor() : DialogFragment() {

    companion object {
        fun getInstance(callback: Callback): CustomDialogFragment {
            return Bundle().apply {
                putSerializable("callback", callback)
            }.let {
                CustomDialogFragment().apply { arguments = it }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        arguments?.let {
            saveCallback = it.getSerializable("callback")!! as Callback
        }
    }

    private lateinit var saveCallback: Callback

    interface Callback : Serializable {
        fun setText(text: String)
    }

    private var _binding: FragmentCustomDialogBinding? = null
    private val binding get() = _binding!!

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentCustomDialogBinding.inflate(inflater, container, false)
        return binding.root
    }

    override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val dialog = super.onCreateDialog(savedInstanceState)
        dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
        return dialog
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setupViews()
    }

    private fun setupViews() = binding.run {
        closeIcon.setOnClickListener { closeDialog() }
        cancelButton.setOnClickListener { closeDialog() }
        saveButton.setOnClickListener { onSaveClicked() }
    }

    private fun onSaveClicked() = binding.run {
        val strMaxPrice = maxPriceInput.text.toString()
        saveCallback.setText(strMaxPrice)
        closeDialog()
    }

    override fun onAttachFragment(childFragment: Fragment) {
        super.onAttachFragment(childFragment)
        saveCallback = childFragment as Callback
    }

    private fun closeDialog() {
        requireDialog().hide()
    }
}

And on the fragment that displays this dialog:

class FirstFragment : Fragment(), Callback {

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.buttonFirst.setOnClickListener {
            val fragmentManager = requireActivity().supportFragmentManager
            val newFragment = CustomDialogFragment.getInstance(callback)
            newFragment.show(fragmentManager, "dialog")
        }
    }

    override fun setText(text: String) {
        Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
    }
    
    ...
}

However, this is not working because the onAttachFragment method is not being called. Also, this method is deprecated, so... Let's try another way.

Second attempt: passing the callback via getInstance()

I remember that whenever you pass an argument to a FragmentDialog, you should put this value in the bundle of the Dialog so when things like configuration changes happen, your value will not be lost. However, an interface can't be saved on a bundle, but a serializable value can. So let's create an interface that implements this method:

companion object {
    fun getInstance(callback: Callback): CustomDialogFragment {
        return Bundle().apply {
            putSerializable("callback", callback)
        }.let {
            CustomDialogFragment().apply { arguments = it }
        }
    }
}

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    arguments?.let {
        saveCallback = it.getSerializable("callback")!! as Callback
    }
}

private lateinit var saveCallback: Callback

interface Callback : Serializable {
    fun setText(text: String)
}

And on the fragment that is going to show this dialog:

class FirstFragment : Fragment(), CustomDialogFragment.Callback {
    
    override fun setText(text: String) {
        Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.buttonFirst.setOnClickListener {
            val fragmentManager = requireActivity().supportFragmentManager
            val newFragment = CustomDialogFragment.getInstance(this)
            newFragment.show(fragmentManager, "dialog")
        }
    }
    ...
}

However this sounded promising and worked (I could call the Dialog, display it and retrieve the value), if I minimize my app after displaying the dialog (before or after closing the dialog), my app crashes with a BadParcelableException:

Fatal Exception: android.os.BadParcelableException
Parcelable encountered IOException writing serializable object (name = com.package.app.MyFragment)

So, that did not work too...

Third attempt: Using navigation and safeargs

I remember that we now have navigation, so let's try with this:

My nav graph:

<fragment
    android:id="@+id/fragment"
    android:name="com.package.app.MyFragment"
    android:label="@string/my_fragment_title"
    tools:layout="@layout/fragment_my">

    <action
        android:id="@+id/to_interestByPriceDialogFragment"
        app:destination="@id/interestByPriceDialogFragment" />

</fragment>

<dialog
    android:id="@+id/customDialogFragment"
    android:name="com.package.app.CustomDialogFragment">

    <argument
        android:name="block"
        app:argType="com.package.app.Callback" />

</dialog>

My CustomFragment:

class CustomDialogFragment : DialogFragment() {

    private val args: CustomDialogFragmentArgs by navArgs()
    private val block = args.block

My activity:

findNavController().navigate(MyFragmentDirections.toCustomDialogFragment(object : Callback {
    override fun setText(text: String) {
        Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
    }
}))

The callback:

interface Callback : java.io.Serializable {
    fun setText(text: Int)
}

That also did not work, displaying this error:

Caused by: java.lang.IllegalStateException: Fragment CustomDialogFragment{7fd896f} (a78e55c0-62f1-48c2-bc19-3f9d6f85adb2) has null arguments

I know one way to "solve the problem", which would be passing the callback as a lambda via the CustomDialog constructor, but I'm not sure if it's a good practice.

Any idea on how to solve this properly?

Leonardo Sibela
  • 1,613
  • 1
  • 18
  • 39

2 Answers2

0

Well, I'm not sure how all the cool kids are doing it, but let me explain how I chose to do it and the reasons why:

How I'm doing it:

The fragment dialog class:

class CustomDialogFragment : DialogFragment() {

    companion object {
        private const val CALLBACK_KEY = "callback_key"
        val TAG = CustomDialogFragment ::class.simpleName

        fun getInstance(callback: Callback): CustomDialogFragment {
            return Bundle().apply {
                putSerializable(CALLBACK_KEY, callback)
            }.let {
                CustomDialogFragment().apply { arguments = it }
            }
        }
    }
    
    private lateinit var saveCallback: Callback

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        callback = arguments?.getSerializable(CALLBACK_KEY) as Callback
    }

    interface Callback : Serializable {
        fun setText(text: String)
    }

    ...
}

The fragment calling the Dialog:

fun displayDialog() {
    val fragmentManager = requireActivity().supportFragmentManager
    val fragment = CustomDialogFragment .getInstance(callback)
    fragment.show(fragmentManager, CustomDialogFragment .TAG)
}

private val callback = object : CustomDialogFragment .Callback {
    override fun setText(text: String) {
        Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
    }
}

Why I'm doing this way?

Why I'm not using the constructor?

On a process death, android will kill my Dialog and try to recreate it. In this process, android will try and call a default constructor that does not receive any parameter. So, passing anything to a constructor is not an option if you want to handle this scenario.

He also saves the bundle of the fragment that was destroyed, so it can be used in the next fragment.

Why not using a function type (String) -> Unit instead of the interface since this would allow me to pass a lambada as a parameter to the getInstace method, which would be more clean than an anonymous object?

Since I have to store this value in the Bundle and it's limited to a few types (primitives, parcelable, serializable...) a function is not an option. So, function is also not something we can use.

Why not Parcelable, once in In Serialization, a marshalling operation is performed on a Java Virtual Machine (JVM) using the Java reflection API, that ends up creating a lot of garbage objects ant therefore is slower in comparison to Parcelable?

Serializable is slower indeed, but If you try to implement Parcelable into an interface (i.e. interface Callback : Parcelable), it will force whoever implements this interface to implement [describeContents][7] and [writeToParcel][7], since you can't use @Parcelize anotation to make it cleaner, once it requires the construct type to be a concrete class (not an abstract class, neither an interface). The boilerplate code makes me prefere Serializable.

Since the callback has only one function, why not us SAM conversion?

The SAM conversion will not work here because the interface implements serializable and as far as I know, there's no way to pass a lambda.

What about making the Callback interface extend Function1<String, Unit> (a functional interface that represents a function with a single argument of type VehicleAttributes and no return value)? Wont it allow us to create an instance of the Callback interface using a lambda expression?

No, that does not work as well. If you try to do this:

interface Callback : Function1<String, Unit>, Serializable {
    override fun invoke(text: String)
}

val fragment = CustomDialogFragment .getInstance(Callback { text ->
    Toast.makeText(requireContext(), text, Toast.LENGTH_LONG).show()
})

Chat GPT gave me a tip to use this. I'm really not familiar to kotlin.jvm.functions, but I was not able to make it work as well.

So, in sumary, the winner is Serializable interface with a function as a callback.

But if anyone has a better option, just post a comment and I can edit my answer, or post another answer :)

Leonardo Sibela
  • 1,613
  • 1
  • 18
  • 39
0

Usually, you should not use parcels/serializers to pass callbacks across processes. Read this. Try using setFragmentResultListener and setFragmentResult to gracefully use FragmentManager for this purpose.

Sarfaraz
  • 324
  • 1
  • 3
  • 13