diff --git a/src/Compatibility/Core/src/Android/Renderers/EntryRenderer.cs b/src/Compatibility/Core/src/Android/Renderers/EntryRenderer.cs index f2acf4d9d985..cf86328de43f 100644 --- a/src/Compatibility/Core/src/Android/Renderers/EntryRenderer.cs +++ b/src/Compatibility/Core/src/Android/Renderers/EntryRenderer.cs @@ -366,6 +366,7 @@ void OnKeyboardBackPressed(object sender, EventArgs eventArgs) Control?.ClearFocus(); } + [PortHandler] void UpdateMaxLength() { var currentFilters = new List(EditText?.GetFilters() ?? new IInputFilter[0]); diff --git a/src/Compatibility/Core/src/iOS/Renderers/EntryRenderer.cs b/src/Compatibility/Core/src/iOS/Renderers/EntryRenderer.cs index af236d754bcf..81f2059047fd 100644 --- a/src/Compatibility/Core/src/iOS/Renderers/EntryRenderer.cs +++ b/src/Compatibility/Core/src/iOS/Renderers/EntryRenderer.cs @@ -388,6 +388,7 @@ void UpdateCharacterSpacing() UpdateAttributedPlaceholder(placeHolder); } + [PortHandler] void UpdateMaxLength() { var currentControlText = Control.Text; @@ -396,6 +397,7 @@ void UpdateMaxLength() Control.Text = currentControlText.Substring(0, Element.MaxLength); } + [PortHandler] bool ShouldChangeCharacters(UITextField textField, NSRange range, string replacementString) { var newLength = textField?.Text?.Length + replacementString?.Length - range.Length; diff --git a/src/Controls/samples/Controls.Sample/Pages/MainPage.cs b/src/Controls/samples/Controls.Sample/Pages/MainPage.cs index 0ab4afbfb1c5..b74636dcd560 100644 --- a/src/Controls/samples/Controls.Sample/Pages/MainPage.cs +++ b/src/Controls/samples/Controls.Sample/Pages/MainPage.cs @@ -98,6 +98,7 @@ void SetupMauiLayout() verticalStack.Add(new Entry { IsTextPredictionEnabled = false }); verticalStack.Add(new Entry { Placeholder = "This should be placeholder text" }); verticalStack.Add(new Entry { Text = "This should be read only property", IsReadOnly = true }); + verticalStack.Add(new Entry { MaxLength = 5, Placeholder = "MaxLength text" }); verticalStack.Add(new ProgressBar { Progress = 0.5 }); verticalStack.Add(new ProgressBar { Progress = 0.5, BackgroundColor = Color.LightCoral }); diff --git a/src/Core/src/Core/ITextInput.cs b/src/Core/src/Core/ITextInput.cs index 984688a51d62..4dbe0ccf8aec 100644 --- a/src/Core/src/Core/ITextInput.cs +++ b/src/Core/src/Core/ITextInput.cs @@ -14,5 +14,10 @@ public interface ITextInput : IText, IPlaceholder /// Gets a value indicating whether or not the view is read-only. /// bool IsReadOnly { get; } + + /// + /// Gets the maximum allowed length of input. + /// + int MaxLength { get; } } } \ No newline at end of file diff --git a/src/Core/src/Handlers/Entry/EntryHandler.Android.cs b/src/Core/src/Handlers/Entry/EntryHandler.Android.cs index f3048df44ca3..b219deb7d531 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.Android.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.Android.cs @@ -60,6 +60,11 @@ public static void MapIsTextPredictionEnabled(EntryHandler handler, IEntry entry handler.TypedNativeView?.UpdateIsTextPredictionEnabled(entry); } + public static void MapMaxLength(EntryHandler handler, IEntry entry) + { + handler.TypedNativeView?.UpdateMaxLength(entry); + } + public static void MapPlaceholder(EntryHandler handler, IEntry entry) { handler.TypedNativeView?.UpdatePlaceholder(entry); diff --git a/src/Core/src/Handlers/Entry/EntryHandler.Standard.cs b/src/Core/src/Handlers/Entry/EntryHandler.Standard.cs index 4900bd45e574..26b0317259d6 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.Standard.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.Standard.cs @@ -11,6 +11,7 @@ public static void MapTextColor(IViewHandler handler, IEntry entry) { } public static void MapIsPassword(IViewHandler handler, IEntry entry) { } public static void MapHorizontalTextAlignment(IViewHandler handler, IEntry entry) { } public static void MapIsTextPredictionEnabled(IViewHandler handler, IEntry entry) { } + public static void MapMaxLength(IViewHandler handler, IEntry entry) { } public static void MapPlaceholder(IViewHandler handler, IEntry entry) { } public static void MapIsReadOnly(IViewHandler handler, IEntry entry) { } public static void MapFont(IViewHandler handler, IEntry entry) { } diff --git a/src/Core/src/Handlers/Entry/EntryHandler.cs b/src/Core/src/Handlers/Entry/EntryHandler.cs index 2a42051b9b2f..7815e5be094c 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.cs @@ -9,6 +9,7 @@ public partial class EntryHandler [nameof(IEntry.IsPassword)] = MapIsPassword, [nameof(IEntry.HorizontalTextAlignment)] = MapHorizontalTextAlignment, [nameof(IEntry.IsTextPredictionEnabled)] = MapIsTextPredictionEnabled, + [nameof(IEntry.MaxLength)] = MapMaxLength, [nameof(IEntry.Placeholder)] = MapPlaceholder, [nameof(IEntry.IsReadOnly)] = MapIsReadOnly, [nameof(IEntry.Font)] = MapFont, diff --git a/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs b/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs index cbdc8023520a..362d224a86e9 100644 --- a/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs +++ b/src/Core/src/Handlers/Entry/EntryHandler.iOS.cs @@ -1,4 +1,5 @@ using System; +using Foundation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.Platform.iOS; using UIKit; @@ -25,6 +26,7 @@ protected override void ConnectHandler(MauiTextField nativeView) nativeView.EditingChanged += OnEditingChanged; nativeView.EditingDidEnd += OnEditingEnded; nativeView.TextPropertySet += OnTextPropertySet; + nativeView.ShouldChangeCharacters += OnShouldChangeCharacters; } protected override void DisconnectHandler(MauiTextField nativeView) @@ -32,6 +34,7 @@ protected override void DisconnectHandler(MauiTextField nativeView) nativeView.EditingChanged -= OnEditingChanged; nativeView.EditingDidEnd -= OnEditingEnded; nativeView.TextPropertySet -= OnTextPropertySet; + nativeView.ShouldChangeCharacters -= OnShouldChangeCharacters; } protected override void SetupDefaults(MauiTextField nativeView) @@ -45,6 +48,9 @@ public override Size GetDesiredSize(double widthConstraint, double heightConstra public static void MapText(EntryHandler handler, IEntry entry) { handler.TypedNativeView?.UpdateText(entry); + + // Any text update requires that we update any attributed string formatting + MapFormatting(handler, entry); } public static void MapTextColor(EntryHandler handler, IEntry entry) @@ -67,6 +73,11 @@ public static void MapIsTextPredictionEnabled(EntryHandler handler, IEntry entry handler.TypedNativeView?.UpdateIsTextPredictionEnabled(entry); } + public static void MapMaxLength(EntryHandler handler, IEntry entry) + { + handler.TypedNativeView?.UpdateMaxLength(entry); + } + public static void MapPlaceholder(EntryHandler handler, IEntry entry) { handler.TypedNativeView?.UpdatePlaceholder(entry); @@ -82,6 +93,27 @@ public static void MapReturnType(EntryHandler handler, IEntry entry) handler.TypedNativeView?.UpdateReturnType(entry); } + public static void MapFont(EntryHandler handler, IEntry entry) + { + _ = handler.Services ?? throw new InvalidOperationException($"{nameof(Services)} should have been set by base class."); + + var fontManager = handler.Services.GetRequiredService(); + + handler.TypedNativeView?.UpdateFont(entry, fontManager); + } + + public static void MapFormatting(EntryHandler handler, IEntry entry) + { + handler.TypedNativeView?.UpdateMaxLength(entry); + + // Update all of the attributed text formatting properties + handler.TypedNativeView?.UpdateCharacterSpacing(entry); + + // Setting any of those may have removed text alignment settings, + // so we need to make sure those are applied, too + handler.TypedNativeView?.UpdateHorizontalTextAlignment(entry); + } + void OnEditingChanged(object? sender, EventArgs e) => OnTextChanged(); void OnEditingEnded(object? sender, EventArgs e) => OnTextChanged(); @@ -101,13 +133,23 @@ void OnTextChanged() VirtualView.Text = nativeText; } - public static void MapFont(EntryHandler handler, IEntry entry) + bool OnShouldChangeCharacters(UITextField textField, NSRange range, string replacementString) { - _ = handler.Services ?? throw new InvalidOperationException($"{nameof(Services)} should have been set by base class."); + var currLength = textField?.Text?.Length ?? 0; - var fontManager = handler.Services.GetRequiredService(); + // fix a crash on undo + if (range.Length + range.Location > currLength) + return false; - handler.TypedNativeView?.UpdateFont(entry, fontManager); + if (VirtualView == null || TypedNativeView == null) + return false; + + var addLength = replacementString?.Length ?? 0; + var remLength = range.Length; + + var newLength = currLength + addLength - remLength; + + return newLength <= VirtualView.MaxLength; } } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/EditTextExtensions.cs b/src/Core/src/Platform/Android/EditTextExtensions.cs index 60935ad17ae9..10a5fcdad32d 100644 --- a/src/Core/src/Platform/Android/EditTextExtensions.cs +++ b/src/Core/src/Platform/Android/EditTextExtensions.cs @@ -1,4 +1,5 @@ -using Android.Content.Res; +using System.Collections.Generic; +using Android.Content.Res; using Android.Text; using Android.Util; using AndroidX.AppCompat.Widget; @@ -76,9 +77,27 @@ public static void UpdateIsTextPredictionEnabled(this AppCompatEditText editText public static void UpdateIsTextPredictionEnabled(this AppCompatEditText editText, IEditor editor) { if (editor.IsTextPredictionEnabled) - return; + editText.InputType &= ~InputTypes.TextFlagNoSuggestions; + else + editText.InputType |= InputTypes.TextFlagNoSuggestions; + } + + public static void UpdateMaxLength(this AppCompatEditText editText, IEntry entry) + { + var currentFilters = new List(editText?.GetFilters() ?? new IInputFilter[0]); + + for (var i = 0; i < currentFilters.Count; i++) + { + if (currentFilters[i] is InputFilterLengthFilter) + { + currentFilters.RemoveAt(i); + break; + } + } + + currentFilters.Add(new InputFilterLengthFilter(entry.MaxLength)); - editText.InputType |= InputTypes.TextFlagNoSuggestions; + editText?.SetFilters(currentFilters.ToArray()); } public static void UpdatePlaceholder(this AppCompatEditText editText, IEntry entry) diff --git a/src/Core/src/Platform/iOS/TextFieldExtensions.cs b/src/Core/src/Platform/iOS/TextFieldExtensions.cs index 3bf91dda39f8..0decc64bfdc2 100644 --- a/src/Core/src/Platform/iOS/TextFieldExtensions.cs +++ b/src/Core/src/Platform/iOS/TextFieldExtensions.cs @@ -54,6 +54,14 @@ public static void UpdateIsTextPredictionEnabled(this UITextField textField, IEn textField.AutocorrectionType = UITextAutocorrectionType.No; } + public static void UpdateMaxLength(this UITextField textField, IEntry entry) + { + var currentControlText = textField.Text; + + if (currentControlText?.Length > entry.MaxLength) + textField.Text = currentControlText.Substring(0, entry.MaxLength); + } + public static void UpdatePlaceholder(this UITextField textField, IEntry entry) { textField.Placeholder = entry.Placeholder; diff --git a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.cs b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.cs index d4d4f7fc3e69..aa9efd175ddd 100644 --- a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.cs +++ b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.cs @@ -55,5 +55,22 @@ public async Task IsTextPredictionEnabledCorrectly(bool isEnabled) await ValidatePropertyInitValue(editor, () => editor.IsTextPredictionEnabled, GetNativeIsTextPredictionEnabled, isEnabled); } + + [Theory(DisplayName = "IsTextPredictionEnabled Updates Correctly")] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task IsTextPredictionEnabledUpdatesCorrectly(bool setValue, bool unsetValue) + { + var editor = new EditorStub(); + + await ValidatePropertyUpdatesValue( + editor, + nameof(IEditor.IsTextPredictionEnabled), + GetNativeIsTextPredictionEnabled, + setValue, + unsetValue); + } } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs index 4ad88dedb156..0e6e4b5d9100 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.cs @@ -1,5 +1,4 @@ -using System; -using System.Threading.Tasks; +using System.Threading.Tasks; using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; using Xunit; @@ -237,5 +236,53 @@ public async Task TextChangedEventsFireCorrectly(string initialText, string newT else Assert.Equal(0, eventFiredCount); } + + [Theory(DisplayName = "MaxLength Initializes Correctly")] + [InlineData(2)] + [InlineData(5)] + [InlineData(8)] + [InlineData(10)] + public async Task MaxLengthInitializesCorrectly(int maxLength) + { + const string text = "Lorem ipsum dolor sit amet"; + var expectedText = text.Substring(0, maxLength); + + var entry = new EntryStub() + { + MaxLength = maxLength, + Text = text + }; + + var nativeText = await GetValueAsync(entry, GetNativeText); + + Assert.Equal(expectedText, nativeText); + Assert.Equal(expectedText, entry.Text); + } + + [Theory(DisplayName = "MaxLength Clips Native Text Correctly")] + [InlineData(2)] + [InlineData(5)] + [InlineData(8)] + [InlineData(10)] + public async Task MaxLengthClipsNativeTextCorrectly(int maxLength) + { + const string text = "Lorem ipsum dolor sit amet"; + var expectedText = text.Substring(0, maxLength); + + var entry = new EntryStub() + { + MaxLength = maxLength, + }; + + var nativeText = await GetValueAsync(entry, handler => + { + entry.Text = text; + + return GetNativeText(handler); + }); + + Assert.Equal(expectedText, nativeText); + Assert.Equal(expectedText, entry.Text); + } } -} +} \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index ce9bd62d5f89..dd38f0be953a 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -85,7 +85,6 @@ public async Task ReturnTypeInitializesCorrectly() Assert.Equal(expectedValue, values.NativeViewValue); } - UITextField GetNativeEntry(EntryHandler entryHandler) => (UITextField)entryHandler.View; diff --git a/src/Core/tests/DeviceTests/Stubs/EntryStub.cs b/src/Core/tests/DeviceTests/Stubs/EntryStub.cs index 026fbec2f611..911b2a8000f6 100644 --- a/src/Core/tests/DeviceTests/Stubs/EntryStub.cs +++ b/src/Core/tests/DeviceTests/Stubs/EntryStub.cs @@ -26,6 +26,8 @@ public string Text public Font Font { get; set; } + public int MaxLength { get; set; } = int.MaxValue; + public TextAlignment HorizontalTextAlignment { get; set; } public ReturnType ReturnType { get; set; }