· 4 min read Posted by Jigar Brahmbhatt

KMP: A Use Case For Common UI Behavior

This post demonstrates how we used common KMP code for a common UI feature across three platforms.

Requirement

Recently, we got a new feature requirement for the SDK we’re developing using Kotlin Multiplatform (KMP). The SDK has a typical UI form with a variety of fields in it that the user fills out. One of the fields is a phone number field. The requirement was to auto-format the number as the user types out. The end behavior would look like this:

Phone number entry

How we did it initially

We usually divide KMP work based on UI stories for each platform and a task for any required work in the commonMain layer.

For deeper dive into how we divide KMP work, checkout Kevin’s post about it

Following the same approach, first we added a couple of utility methods in common code to format and unformat the phone number.

commonMain

fun formatPhoneNumber(number: String): String {
    // return formatted number using regex
}

fun unformatPhoneNumber(number: CharSequence): String {
    // return unformatted number using regex
}

Then we worked on individual platform UI implementation.

Android

We implemented a custom TextWatcher and handled the logic in beforeTextChanged and afterTextChanged methods.

iOS

On iOS, we used @objc func textFieldDidChange(_ sender: UITextField) to handle formatting while entering the number entering, and func textField() with the shouldChangeCharactersIn option to format the number when deleting.

Web

On ReactJS, we used onChange of the TextField to implement custom logic for formatting.

What worked and what went wrong

What worked was that each platform performed the same for the general use case of entering and deleting the number due to having common knowledge and JIRA tickets for them.

What differed between each platform was how the individual developer approached edge cases around entering and deleting the input from a specific cursor position.

For example, consider the input like this (123) 456-7890. Now user wants to remove the number 6 and manually sets the cursor position after the dash(-) like this (123) 456-|7890. Once the user presses the delete key, the number 6 should be removed, and the new cursor should get positioned after the number 5 like this (123) 45|7-890.

This logic got more complex as we found more and more edge cases at different cursor positions.

What went wrong with our approach was that we started fixing these edge cases on each platform because each had unique bugs.

common Code At The Rescue

While fixing those edge cases on each platform, we noticed a pattern in all implementations. We were ultimately doing some string manipulation based on the current cursor position to determine a new string to show on the screen along with the new cursor position.

We realized an opportunity to combine the logic in common code by just introducing methods that would take existing text and cursor position as inputs and provide a new string and a cursor position back.

After doing a refactoring exercise for that, we came up with two methods

/**
 * @param lastCursorIndex 0-based index
 */
@JsExport
fun getTextAndCursorPositionOnTelephoneEnter(
    lastCursorIndex: Int,
    newText: String,
): TelephoneFormattingResult

/**
 * @param deletedCharIndex 0-based index
 */
@JsExport
fun getTextAndCursorPositionOnTelephoneDelete(
    deletedCharIndex: Int,
    oldText: String
): TelephoneFormattingResult

@JsExport
class TelephoneFormattingResult(
    @JsName("text") val text: String,
    @JsName("cursorPosition") val cursorPosition: Int,
)

It wasn’t that straightforward to come up with the inputs for the methods above because all three platforms have different ways of getting cursor indices. While entering, the index of the last cursor position before entering the new character worked out well. For the deletion, the index of the deleted character worked out better.

Conclusion

We learned that just because some feature is only UI related, we shouldn’t jump to the conclusion that there won’t be any common code.

In fact, for the feature mentioned in this post, in the end, we had the most crucial logic implemented in the common code. Now, each platform’s implementation remains bare-bones, where we use platform-specific APIs to extract existing text/position and rely on common logic to determine new text and cursor position.

As a bonus, we could write extensive unit tests for the common logic to cover all the edge cases. On top of that, we would have only one implementation to modify if we ever decide to change the functionality of this feature or find a bug.


Thanks for reading! Let me know in the comments if you have questions. Also, you can reach out to me at @shaktiman_droid on Twitter, LinkedIn or Kotlin Slack. And if you find all this interesting, maybe you’d like to work with or work at Touchlab.