Skip to content

CursorPosition not calculated correctly on behaviors events for iOS devices #32483

@VictorJanikian

Description

@VictorJanikian

Description

I'm seeing vey inconstant CursorPoint calculations for entries in iOS devices.

I have the following behavior:

using System.Text;

namespace br.com.lb.lilaapp.Behaviors
{
    public class DualMaskedBehavior : Behavior<Entry>
    {
        private bool _isUpdating;

        protected override void OnAttachedTo(Entry bindable)
        {
            base.OnAttachedTo(bindable);
            bindable.TextChanged += OnTextChangedAsync;
        }

        protected override void OnDetachingFrom(Entry bindable)
        {
            base.OnDetachingFrom(bindable);
            bindable.TextChanged -= OnTextChangedAsync;
        }

        private async void OnTextChangedAsync(object sender, TextChangedEventArgs e)
        {
            if (_isUpdating || sender is not Entry entry)
                return;

            string oldText = e.OldTextValue ?? string.Empty;
            string newText = e.NewTextValue ?? string.Empty;

            string digits = ExtractDigits(newText);

            string masked = ApplyMask(digits);

            if (entry.Text == masked)
                return;

            _isUpdating = true;

            int oldCursor = entry.CursorPosition;
            entry.Text = masked;

            // Deixa o ciclo de UI aplicar o novo texto antes de mexer no cursor
            await Task.Yield();

#if ANDROID
            entry.CursorPosition = CalculateCursorPositionAndroid(oldCursor, oldText, masked);
#endif

#if IOS
            entry.CursorPosition = CalculateCursorPositionIos(oldText, masked);
#endif

            entry.SelectionLength = 0;

            _isUpdating = false;
        }

        private static string ExtractDigits(string input) =>
            new string(input.Where(char.IsDigit).ToArray());

        private static string ApplyMask(string digits)
        {
            if (digits.Length <= 11)
                return ApplyCpfMask(digits);

            if (digits.Length <= 15)
                return ApplyCnsMask(digits);

            return digits;
        }

        private static string ApplyCpfMask(string digits)
        {
            if (digits.Length > 11)
                digits = digits[..11];

            var sb = new StringBuilder();

            for (int i = 0; i < digits.Length; i++)
            {
                sb.Append(digits[i]);

                if ((i == 2 || i == 5) && i != digits.Length - 1)
                    sb.Append('.');

                if (i == 8 && i != digits.Length - 1)
                    sb.Append('-');
            }

            return sb.ToString();
        }

        private static string ApplyCnsMask(string digits)
        {
            return digits;
        }

        private static int CalculateCursorPositionAndroid(int oldCursor, string oldText, string newText)
        {

            if (newText.Length > oldText.Length)
            {

                int newCursorPosition = oldCursor + 1;

                if (justPassedOverAMaskChar(newText, newCursorPosition))
                {
                    newCursorPosition++;
                }

                return newCursorPosition;
            }
            else
            {
                int newCursorPosition = Math.Max(oldCursor - 1, 0);

                if (previousCharIsAMaskChar(oldText, newCursorPosition))
                {
                    newCursorPosition--;
                }

                return newCursorPosition;

            }
        }

        private static bool previousCharIsAMaskChar(string text, int newCursorPosition)
        {
            return newCursorPosition > 0 && newCursorPosition <= text.Length && !char.IsDigit(text[newCursorPosition - 1]);
        }

        private static bool justPassedOverAMaskChar(string text, int newCursorPosition)
        {
            return newCursorPosition < text.Length && Char.IsDigit(text[newCursorPosition]) && !Char.IsDigit(text[newCursorPosition - 1]);
        }

        private static int CalculateCursorPositionIos(string oldText, string newText)
        {
            var oldTextArray = oldText.ToCharArray();
            var newTextArray = newText.ToCharArray();

            int cursorPosition = newText.Length;
            if (MasksJustSwitched(oldText, newText))
                return cursorPosition;

            bool newTextIsBigger = newText.Length > oldText.Length;

            for (int charPosition = 0; charPosition < oldTextArray.Length; charPosition++)
            {
                if (lastDigitWasBackspaced(charPosition, newTextArray))
                {
                    cursorPosition = newText.Length;
                    break;
                }

                if (newTextArray[charPosition] != oldTextArray[charPosition] && Char.IsDigit(oldTextArray[charPosition]))
                {
                    cursorPosition = newTextIsBigger ? charPosition + 1 : charPosition;
                    break;
                }
            }
            return cursorPosition;
        }

        private static bool MasksJustSwitched(string oldchar, string newChar)
        {
            return (newChar.Length == 12 && oldchar.Length == 14)
                || (oldchar.Length == 12 && newChar.Length == 14);
        }

        private static bool lastDigitWasBackspaced(int charPosition, char[] newTextArray)
        {
            return charPosition >= newTextArray.Length;
        }
    }
}`

This code apply conditional masking depending on the number of characters typed in the entry.

This line:

int oldCursor = entry.CursorPosition;

Is pretty much precise in Android, capturing the correct cursor position regardless of the string composition.

But on iOS devices it is chaotic. Sometimes it returns 0, sometimes a random number; specially after it stops being a digits-only charset. That's why I need a special treatment for iOS cases where I try to "guess" the cursor position based on the differences between the old and the new strings.

The erratic behavior must follow some pattern, but I could not identify it.

### Steps to Reproduce

1. Create a custom entry:
public class RoundedEntryNoEmojis : RoundedEntry
{
}

2. Give it a special treatment on Android:


#if ANDROID

        Microsoft.Maui.Handlers.EntryHandler.Mapper.AppendToMapping("DisableEmojiCompat", (handler, view) =>
        {
            if (view is RoundedEntryNoEmojis)
            {
                handler.PlatformView.EmojiCompatEnabled = false;
            }
        });

#endif
}


3 . Created a custom behavior as above

4. Apply the behavior to the custom entry. Example:
5. 
                    <custom:RoundedEntryNoEmojis Placeholder="CPF"
                                         Text="{Binding CPForCNS}"
                                         HorizontalTextAlignment="Center"
                                         VerticalTextAlignment="Center"
                                         HeightRequest="50"
                                         Keyboard="Numeric"
                                         FontFamily="RegularFont"
                                         TextColor="{StaticResource DefaultTextColor}"
                                         PlaceholderColor="{StaticResource DefaultPlaceholderColor}"
                                         HorizontalOptions="FillAndExpand"
                                         FontSize="{Binding FontSmall}"
                                         IsEnabled="{Binding Loading, Converter={StaticResource Key=negative}}"
                                         MaxLength="15">

                        <custom:RoundedEntry.Behaviors>
                            <behavior:DualMaskedBehavior />
                        </custom:RoundedEntry.Behaviors>

                    </custom:RoundedEntryNoEmojis>

5. Run on iOS emulator, debug the code and insert a breaking on the entry.CursorPosition calculation.

It should always capture the correct cursor position, but seldom it does.

### Link to public reproduction project repository

_No response_

### Version with bug

9.0.82 SR8.2

### Is this a regression from previous behavior?

Not sure, did not test other versions

### Last version that worked well

_No response_

### Affected platforms

iOS

### Affected platform versions

iOS 17.2

### Did you find any workaround?

I'm comparing the old and new entry strings and guessing the cursor position, but it is not 100% effective.

### Relevant log output

```shell

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-controls-entryEntrypartner/syncfusionIssues / PR's with Syncfusion collaborationplatform/ioss/triagedIssue has been revieweds/verifiedVerified / Reproducible Issue ready for Engineering Triaget/bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status

    Done

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions