· 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:
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 number6
and manually sets the cursor position after thedash(-)
like this(123) 456-|7890
. Once the user presses the delete key, the number6
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.