KMM: A Use case for common UI behavior
Tags: kotlin, kotlinmultiplatform, ux, mobile
Published: 2023-01-30 20:03:37

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 it out. The end behavior would be like this,

![GIF shows phone number typed and deleted in an input field](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9cezytcreny6f88o0rmj.gif)

## 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](https://dev.to/touchlab/dividing-kotlin-multiplatform-work-in-teams-2cad) 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
```kotlin
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](https://developer.android.com/reference/android/text/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,

```Kotlin
/**
 * @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](https://twitter.com/shaktiman_droid) on Twitter, [LinkedIn](https://www.linkedin.com/in/shaktiman-droid/) or [Kotlin Slack](https://slack.kotlinlang.org/). And if you find all this interesting, maybe you'd like to [work with](https://touchlab.co/contact-us/) or [work at](https://touchlab.co/careers-3/) Touchlab.