diff --git a/src/Compatibility/Core/src/Android/Renderers/EditorRenderer.cs b/src/Compatibility/Core/src/Android/Renderers/EditorRenderer.cs index 0a9c3b842c24..291e4068d141 100644 --- a/src/Compatibility/Core/src/Android/Renderers/EditorRenderer.cs +++ b/src/Compatibility/Core/src/Android/Renderers/EditorRenderer.cs @@ -243,6 +243,7 @@ void UpdateInputType() } } + [PortHandler] void UpdateCharacterSpacing() { if (Forms.IsLollipopOrNewer) diff --git a/src/Compatibility/Core/src/iOS/Renderers/EditorRenderer.cs b/src/Compatibility/Core/src/iOS/Renderers/EditorRenderer.cs index f7bc0ad41f0e..0a83d4b21862 100644 --- a/src/Compatibility/Core/src/iOS/Renderers/EditorRenderer.cs +++ b/src/Compatibility/Core/src/iOS/Renderers/EditorRenderer.cs @@ -70,6 +70,7 @@ protected internal override void UpdatePlaceholderText() _placeholderLabel.SizeToFit(); } + [PortHandler("Partially ported")] protected internal override void UpdateCharacterSpacing() { var textAttr = TextView.AttributedText.AddCharacterSpacing(Element.Text, Element.CharacterSpacing); diff --git a/src/Core/src/Handlers/Editor/EditorHandler.Android.cs b/src/Core/src/Handlers/Editor/EditorHandler.Android.cs index e831c804caad..03a461f249d0 100644 --- a/src/Core/src/Handlers/Editor/EditorHandler.Android.cs +++ b/src/Core/src/Handlers/Editor/EditorHandler.Android.cs @@ -25,5 +25,10 @@ public static void MapText(EditorHandler handler, IEditor editor) { handler.TypedNativeView?.UpdateText(editor); } + + public static void MapCharacterSpacing(EditorHandler handler, IEditor editor) + { + handler.TypedNativeView?.UpdateCharacterSpacing(editor); + } } } \ No newline at end of file diff --git a/src/Core/src/Handlers/Editor/EditorHandler.Standard.cs b/src/Core/src/Handlers/Editor/EditorHandler.Standard.cs index f8247d149507..5af79cbefa11 100644 --- a/src/Core/src/Handlers/Editor/EditorHandler.Standard.cs +++ b/src/Core/src/Handlers/Editor/EditorHandler.Standard.cs @@ -7,5 +7,6 @@ public partial class EditorHandler : AbstractViewHandler protected override object CreateNativeView() => throw new NotImplementedException(); public static void MapText(IViewHandler handler, IEditor editor) { } + public static void MapCharacterSpacing(IViewHandler handler, IEditor editor) { } } } \ No newline at end of file diff --git a/src/Core/src/Handlers/Editor/EditorHandler.cs b/src/Core/src/Handlers/Editor/EditorHandler.cs index b64426da7ef1..dbdc329c4ea7 100644 --- a/src/Core/src/Handlers/Editor/EditorHandler.cs +++ b/src/Core/src/Handlers/Editor/EditorHandler.cs @@ -4,7 +4,8 @@ public partial class EditorHandler { public static PropertyMapper EditorMapper = new PropertyMapper(ViewHandler.ViewMapper) { - [nameof(IEditor.Text)] = MapText + [nameof(IEditor.Text)] = MapText, + [nameof(IEditor.CharacterSpacing)] = MapCharacterSpacing }; public EditorHandler() : base(EditorMapper) diff --git a/src/Core/src/Handlers/Editor/EditorHandler.iOS.cs b/src/Core/src/Handlers/Editor/EditorHandler.iOS.cs index 096bbeed9859..66937026a805 100644 --- a/src/Core/src/Handlers/Editor/EditorHandler.iOS.cs +++ b/src/Core/src/Handlers/Editor/EditorHandler.iOS.cs @@ -19,5 +19,10 @@ public static void MapText(EditorHandler handler, IEditor editor) { handler.TypedNativeView?.UpdateText(editor); } + + public static void MapCharacterSpacing(EditorHandler handler, IEditor editor) + { + handler.TypedNativeView?.UpdateCharacterSpacing(editor); + } } } \ No newline at end of file diff --git a/src/Core/src/Platform/Android/EditorExtensions.cs b/src/Core/src/Platform/Android/EditorExtensions.cs index 9becc6ce9c63..ba6493dfdac6 100644 --- a/src/Core/src/Platform/Android/EditorExtensions.cs +++ b/src/Core/src/Platform/Android/EditorExtensions.cs @@ -18,5 +18,10 @@ public static void UpdateText(this AppCompatEditText editText, IEditor editor) editText.SetSelection(text.Length); } + + public static void UpdateCharacterSpacing(this AppCompatEditText editText, IEditor editor) + { + editText.LetterSpacing = editor.CharacterSpacing.ToEm(); + } } } \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/CharacterSpacingExtensions.cs b/src/Core/src/Platform/iOS/CharacterSpacingExtensions.cs new file mode 100644 index 000000000000..12b0d93f6466 --- /dev/null +++ b/src/Core/src/Platform/iOS/CharacterSpacingExtensions.cs @@ -0,0 +1,62 @@ +using Foundation; +using UIKit; + +namespace Microsoft.Maui +{ + public static class CharacterSpacingExtensions + { + public static NSMutableAttributedString? AddCharacterSpacing(this NSAttributedString attributedString, string text, double characterSpacing) + { + if (attributedString == null && characterSpacing == 0) + return null; + + NSMutableAttributedString? mutableAttributedString = attributedString as NSMutableAttributedString; + if (attributedString == null || attributedString.Length == 0) + { + mutableAttributedString = text == null ? new NSMutableAttributedString() : new NSMutableAttributedString(text); + } + else + { + mutableAttributedString = new NSMutableAttributedString(attributedString); + + if (!mutableAttributedString.MutableString.ToString().Equals(text)) + { + mutableAttributedString.MutableString.SetString(new NSString(text)); + } + } + + AddKerningAdjustment(mutableAttributedString, text, characterSpacing); + + return mutableAttributedString; + } + + internal static bool HasCharacterAdjustment(this NSMutableAttributedString mutableAttributedString) + { + if (mutableAttributedString == null) + return false; + + var attributes = mutableAttributedString.GetAttributes(0, out NSRange removalRange); + + for (uint i = 0; i < attributes.Count; i++) + if (attributes.Keys[i] is NSString nSString && nSString == UIStringAttributeKey.KerningAdjustment) + return true; + + return false; + } + + internal static void AddKerningAdjustment(NSMutableAttributedString mutableAttributedString, string? text, double characterSpacing) + { + if (!string.IsNullOrEmpty(text)) + { + if (characterSpacing == 0 && !mutableAttributedString.HasCharacterAdjustment()) + return; + + mutableAttributedString.AddAttribute + ( + UIStringAttributeKey.KerningAdjustment, + NSObject.FromObject(characterSpacing), new NSRange(0, text != null ? text.Length - 1 : 0) + ); + } + } + } +} \ No newline at end of file diff --git a/src/Core/src/Platform/iOS/EditorExtensions.cs b/src/Core/src/Platform/iOS/EditorExtensions.cs index 47485559380c..7637cdb6ff4e 100644 --- a/src/Core/src/Platform/iOS/EditorExtensions.cs +++ b/src/Core/src/Platform/iOS/EditorExtensions.cs @@ -13,5 +13,15 @@ public static void UpdateText(this UITextView textView, IEditor editor) textView.Text = text; } } + + public static void UpdateCharacterSpacing(this UITextView textView, IEditor editor) + { + var textAttr = textView.AttributedText.AddCharacterSpacing(editor.Text, editor.CharacterSpacing); + + if (textAttr != null) + textView.AttributedText = textAttr; + + // TODO: Include AttributedText to Label Placeholder + } } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/AssertionExtensions.iOS.cs b/src/Core/tests/DeviceTests/AssertionExtensions.iOS.cs index 60463bd6bc57..959fa42e847e 100644 --- a/src/Core/tests/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/Core/tests/DeviceTests/AssertionExtensions.iOS.cs @@ -1,6 +1,7 @@ using System; using System.Threading.Tasks; using CoreGraphics; +using Foundation; using Microsoft.Maui.Essentials; using UIKit; using Xunit; @@ -195,5 +196,22 @@ public static UILineBreakMode ToNative(this LineBreakMode mode) => LineBreakMode.MiddleTruncation => UILineBreakMode.MiddleTruncation, _ => throw new ArgumentOutOfRangeException(nameof(mode)) }; + + public static double GetCharacterSpacing(this NSAttributedString text) + { + if (text == null) + return 0; + + var value = text.GetAttribute(UIStringAttributeKey.KerningAdjustment, 0, out var range); + if (value == null) + return 0; + + Assert.Equal(0, range.Location); + Assert.Equal(text.Length, range.Length); + + var kerning = Assert.IsType(value); + + return kerning.DoubleValue; + } } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.Android.cs b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.Android.cs index 7972fc0aeae8..396bc1c8992b 100644 --- a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.Android.cs +++ b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.Android.cs @@ -1,14 +1,55 @@ -using AndroidX.AppCompat.Widget; +using System.Threading.Tasks; +using AndroidX.AppCompat.Widget; +using Microsoft.Maui.DeviceTests.Stubs; using Microsoft.Maui.Handlers; +using Xunit; namespace Microsoft.Maui.DeviceTests { public partial class EditorHandlerTests { - AppCompatEditText GetNativeEditor(EditorHandler editorHandler) => + [Fact(DisplayName = "CharacterSpacing Initializes Correctly")] + public async Task CharacterSpacingInitializesCorrectly() + { + var xplatCharacterSpacing = 4; + + var editor = new EditorStub() + { + CharacterSpacing = xplatCharacterSpacing, + Text = "Test" + }; + + float expectedValue = editor.CharacterSpacing.ToEm(); + + var values = await GetValueAsync(editor, (handler) => + { + return new + { + ViewValue = editor.CharacterSpacing, + NativeViewValue = GetNativeCharacterSpacing(handler) + }; + }); + + Assert.Equal(xplatCharacterSpacing, values.ViewValue); + Assert.Equal(expectedValue, values.NativeViewValue, EmCoefficientPrecision); + } + + AppCompatEditText GetNativeEditor(EditorHandler editorHandler) => (AppCompatEditText)editorHandler.View; string GetNativeText(EditorHandler editorHandler) => GetNativeEditor(editorHandler).Text; - } + + double GetNativeCharacterSpacing(EditorHandler editorHandler) + { + var editText = GetNativeEditor(editorHandler); + + if (editText != null) + { + return editText.LetterSpacing; + } + + return -1; + } + } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.iOS.cs index a99b4aa089c6..f85d205342b0 100644 --- a/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Editor/EditorHandlerTests.iOS.cs @@ -1,14 +1,50 @@ -using Microsoft.Maui.Handlers; +using System.Threading.Tasks; +using Microsoft.Maui.DeviceTests.Stubs; +using Microsoft.Maui.Handlers; using UIKit; +using Xunit; namespace Microsoft.Maui.DeviceTests { public partial class EditorHandlerTests { - UITextView GetNativeEditor(EditorHandler editorHandler) => + [Fact(DisplayName = "CharacterSpacing Initializes Correctly")] + public async Task CharacterSpacingInitializesCorrectly() + { + string originalText = "Test"; + var xplatCharacterSpacing = 4; + + var editor = new EditorStub() + { + CharacterSpacing = xplatCharacterSpacing, + Text = originalText + }; + + var values = await GetValueAsync(editor, (handler) => + { + return new + { + ViewValue = editor.CharacterSpacing, + NativeViewValue = GetNativeCharacterSpacing(handler) + }; + }); + + Assert.Equal(xplatCharacterSpacing, values.ViewValue); + Assert.Equal(xplatCharacterSpacing, values.NativeViewValue); + } + + UITextView GetNativeEditor(EditorHandler editorHandler) => (UITextView)editorHandler.View; string GetNativeText(EditorHandler editorHandler) => GetNativeEditor(editorHandler).Text; - } + + double GetNativeCharacterSpacing(EditorHandler editorHandler) + { + var searchBar = GetNativeEditor(editorHandler); + var textField = searchBar.FindDescendantView(); + + return textField.AttributedText.GetCharacterSpacing(); + } + } } \ No newline at end of file diff --git a/src/Core/tests/DeviceTests/TestBase.Android.cs b/src/Core/tests/DeviceTests/TestBase.Android.cs index b946ddb1b544..4fe6d703263c 100644 --- a/src/Core/tests/DeviceTests/TestBase.Android.cs +++ b/src/Core/tests/DeviceTests/TestBase.Android.cs @@ -1,12 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Text; - namespace Microsoft.Maui.DeviceTests { public partial class TestBase { - public global::Android.Content.Context DefaultContext => + public const int EmCoefficientPrecision = 4; + + public Android.Content.Context DefaultContext => Platform.DefaultContext; } }