Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: take gravity into consideration when calculating selection coordinates on Android #749

Conversation

kirillzyusko
Copy link
Owner

@kirillzyusko kirillzyusko commented Dec 30, 2024

📜 Description

Fixed wrong selection coordinates on Android.

💡 Motivation and Context

The main idea behind this PR is to make selection coordinates consistent between iOS and Android. On Android we can specify textAlignVertical as bottom/top/center and it will affect position of the text inside input. Thus if input height is significantly bigger that text height, then y coordinates will be wrong.

To take y position into consideration I decided to use next code:

val verticalOffset =
  when (gravity) {
    Gravity.CENTER_VERTICAL -> (editTextHeight - textHeight) / 2 + editText.paddingTop
    Gravity.BOTTOM -> editTextHeight - textHeight
    // Default to Gravity.TOP or other cases
    else -> editText.paddingTop * 2
  }

In this case we take a value of textAlignVertical and we can decide which additional offset should be applied.

While it works this change comes with new challenges - since we depend on new variables (such as editTextHeight and textHeight) and these variables can be updated asynchronously (textHeight can have an updated value, but editTextHeight might be updated later), we need to take it into consideration and don't send possibly corrupted events. Below are approaches that I considered to use:

1️⃣ Delaying gathering coords if layout changes its height

Theoretically I could add a condition like:

if (textHeight != lastTextHeight) {
  // input may change size, gather coordinates in next frame
  // view.post {}
  // ... or ...
  // lastTextHeight = textHeight; return
}

But turns out that for updating container layout sometimes it requires 2 frames to actually update layout 🤷‍♂️ And skipping two frames already gives a noticeable delay in terms of visual responsiveness.

2️⃣ Measuring editText to read actual height

The second approach I've been thinking of is using measure to actually get the height of container. While it works sometimes measure returns wrong layout metrics. For example when editText is very big it'll give a smaller height than it actually is. We could use something like max(editText.height, measuredHeight), but when text input shrinks, then it will also produce incorrect metric and will result in incorrect coordinates.

Note

To use measure without affecting layout I used next code

fun View.measureTemporarily(
 widthSpec: Int = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
 heightSpec: Int = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.UNSPECIFIED)
): Pair<Int, Int> {
 // Save the original measured dimensions
 val originalWidth = measuredWidth
 val originalHeight = measuredHeight

 // Perform the custom measurement
 this.measure(widthSpec, heightSpec)
 val measuredWidth = this.measuredWidth
 val measuredHeight = this.measuredHeight

 // Restore the original dimensions
 this.measure(
   View.MeasureSpec.makeMeasureSpec(originalWidth, View.MeasureSpec.EXACTLY),
   View.MeasureSpec.makeMeasureSpec(originalHeight, View.MeasureSpec.EXACTLY)
 )

 return measuredWidth to measuredHeight
}

3️⃣ Use OnPreDraw listener

The only one approach I've been thinking of was a continuation of using FrameScheduler and adding a new variable when we should re-execute handler (i. e. cache editText.height and add lastEditTextHeight). This approach works perfectly and catches a situation, when height is desynchronised and dispatches correct events afterwards. However we almost always will dispatch 2 events when layout changes its height.

To overcome this problem I decided to use OnPreDraw listener instead of FrameScheduler (Choreographer). And it gives much better results (because OnPreDraw runs after layout calculation, so we have better chances to read an actual value):

OnPreDraw FrameScheduler
Screen.Recording.2025-01-03.at.10.12.50.mov
Screen.Recording.2025-01-03.at.10.18.36.mov

Pay an attention to how blue rectangle moves when cursor moves to a new line and input grows - in case of FrameScheduler it moves asynchronously in two stages.

However turns out, that Android may still schedule layout updates in two frames 🤯 So I couldn't get rid off lastEditTextHeight variable. OnPreDraw gives much better result in terms of synchronization, but lastEditTextHeight check still needed to work properly.

📢 Changelog

E2E

Android

  • use OnPreDraw listener instead of FrameScheduler to read more actual value of height;
  • take gravity into consideration;
  • simply use getLineBottom/getLineTop without math operations with ascendant/descendant;
  • cache editTextHeight along with start/end;
  • execute layout === null check before updating cached values.

🤔 How Has This Been Tested?

Tested manually on Medium Phone API 35 and via e2e on API 28 and API 31.

📸 Screenshots (if appropriate):

image

📝 Checklist

  • CI successfully passed
  • I added new mocks and corresponding unit-tests if library API was changed

@kirillzyusko kirillzyusko added 🐛 bug Something isn't working 🤖 android Android specific labels Dec 30, 2024
@kirillzyusko kirillzyusko self-assigned this Dec 30, 2024
Copy link
Contributor

github-actions bot commented Dec 30, 2024

📊 Package size report

Current size Target Size Difference
164281 bytes 164463 bytes -182 bytes 📉

@kirillzyusko kirillzyusko marked this pull request as ready for review January 3, 2025 09:29
@kirillzyusko kirillzyusko merged commit a106fb3 into main Jan 3, 2025
21 checks passed
@kirillzyusko kirillzyusko deleted the fix/take-gravity-into-consideration-when-selection-changed-on-android branch January 3, 2025 12:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
🤖 android Android specific 🐛 bug Something isn't working
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant