diff --git a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs index a177c2c8cdf3..6fd23de011cb 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/ListView/iOS/ListViewRenderer.cs @@ -35,7 +35,6 @@ public class ListViewRenderer : ViewRenderer IPlatformViewHandler _headerRenderer; IPlatformViewHandler _footerRenderer; - KeyboardInsetTracker _insetTracker; RectangleF _previousFrame; ScrollToRequestedEventArgs _requestedScroll; @@ -65,7 +64,6 @@ public ListViewRenderer() : base(Mapper, CommandMapper) public override void LayoutSubviews() { - _insetTracker?.OnLayoutSubviews(); base.LayoutSubviews(); double height = Bounds.Height; @@ -88,10 +86,7 @@ public override void LayoutSubviews() } if (_previousFrame != Frame) - { _previousFrame = Frame; - _insetTracker?.UpdateInsets(); - } } protected override void SetBackground(Brush brush) @@ -138,12 +133,6 @@ protected override void Dispose(bool disposing) if (disposing) { - if (_insetTracker != null) - { - _insetTracker.Dispose(); - _insetTracker = null; - } - if (Element != null) { var templatedItems = TemplatedItemsView.TemplatedItems; @@ -242,13 +231,6 @@ protected override void OnElementChanged(ElementChangedEventArgs e) _tableViewController.TableView.SectionHeaderTopPadding = new nfloat(0); _backgroundUIView = _tableViewController.TableView.BackgroundView; - - _insetTracker = new KeyboardInsetTracker(_tableViewController.TableView, () => Control.Window, insets => Control.ContentInset = Control.ScrollIndicatorInsets = insets, point => - { - var offset = Control.ContentOffset; - offset.Y += point.Y; - Control.SetContentOffset(offset, true); - }, this); } var listView = e.NewElement; diff --git a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellScrollViewTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellScrollViewTracker.cs index ffdc14e87e59..8640688ce1f6 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellScrollViewTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/Shell/iOS/ShellScrollViewTracker.cs @@ -6,6 +6,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { + [Obsolete("Scrolling is now handled by KeyboardAutoManagerScroll.")] public class ShellScrollViewTracker : IDisposable, IShellContentInsetObserver { #region IShellContentInsetObserver diff --git a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs index 050799ac84af..4f1fe044e0cc 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/TableView/iOS/TableViewRenderer.cs @@ -12,7 +12,6 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility public class TableViewRenderer : ViewRenderer { const int DefaultRowHeight = 44; - KeyboardInsetTracker _insetTracker; UIView _originalBackgroundView; RectangleF _previousFrame; @@ -28,23 +27,16 @@ protected override Size MinimumSize() public override void LayoutSubviews() { - _insetTracker?.OnLayoutSubviews(); base.LayoutSubviews(); if (_previousFrame != Frame) - { _previousFrame = Frame; - _insetTracker?.UpdateInsets(); - } } protected override void Dispose(bool disposing) { - if (disposing && _insetTracker != null) + if (disposing) { - _insetTracker.Dispose(); - _insetTracker = null; - var viewsToLookAt = new Stack(Subviews); while (viewsToLookAt.Count > 0) { @@ -82,23 +74,13 @@ protected override void OnElementChanged(ElementChangedEventArgs e) if (Control == null || Control.Style != style) { if (Control != null) - { - _insetTracker.Dispose(); Control.Dispose(); - } var tv = CreateNativeControl(); _originalBackgroundView = tv.BackgroundView; SetNativeControl(tv); tv.CellLayoutMarginsFollowReadableWidth = false; - - _insetTracker = new KeyboardInsetTracker(tv, () => Control.Window, insets => Control.ContentInset = Control.ScrollIndicatorInsets = insets, point => - { - var offset = Control.ContentOffset; - offset.Y += point.Y; - Control.SetContentOffset(offset, true); - }, this); } SetSource(); diff --git a/src/Controls/src/Core/Compatibility/Handlers/iOS/KeyboardInsetTracker.cs b/src/Controls/src/Core/Compatibility/Handlers/iOS/KeyboardInsetTracker.cs index 6485a15415af..a67421f7c48f 100644 --- a/src/Controls/src/Core/Compatibility/Handlers/iOS/KeyboardInsetTracker.cs +++ b/src/Controls/src/Core/Compatibility/Handlers/iOS/KeyboardInsetTracker.cs @@ -7,6 +7,7 @@ namespace Microsoft.Maui.Controls.Handlers.Compatibility { + [Obsolete("Scrolling is now handled by KeyboardAutoManagerScroll.")] internal class KeyboardInsetTracker : IDisposable { readonly Func _fetchWindow; diff --git a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs index b9cb7c873f56..29e4e259ab86 100644 --- a/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs +++ b/src/Core/src/Handlers/ScrollView/ScrollViewHandler.iOS.cs @@ -5,6 +5,7 @@ using CoreGraphics; using Microsoft.Maui.Graphics; using Microsoft.Maui.Layouts; +using Microsoft.Maui.Platform; using ObjCRuntime; using UIKit; using Size = Microsoft.Maui.Graphics.Size; @@ -17,7 +18,7 @@ public partial class ScrollViewHandler : ViewHandler protected override UIScrollView CreatePlatformView() { - return new UIScrollView(); + return new MauiScrollView(); } protected override void ConnectHandler(UIScrollView platformView) diff --git a/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.iOS.cs b/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.iOS.cs index 9bff4f8466af..2837e13b0a44 100644 --- a/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.iOS.cs +++ b/src/Core/src/Hosting/LifecycleEvents/AppHostBuilderExtensions.iOS.cs @@ -19,11 +19,13 @@ static void OnConfigureLifeCycle(IiOSLifecycleBuilder iOS) .OnPlatformWindowCreated((window) => { window.GetWindow()?.Created(); + KeyboardAutoManagerScroll.Connect(); }) .WillTerminate(app => { // By this point if we were a multi window app, the GetWindow would be null anyway app.GetWindow()?.Destroying(); + KeyboardAutoManagerScroll.Disconnect(); }) .WillEnterForeground(app => { diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs index c0380f87956c..6c90d4874f39 100644 --- a/src/Core/src/Platform/iOS/KeyboardAutoManager.cs +++ b/src/Core/src/Platform/iOS/KeyboardAutoManager.cs @@ -20,7 +20,7 @@ internal static void GoToNextResponderOrResign(UIView view, UIView? customSuperV return; } - var superview = customSuperView ?? view.FindResponder()?.View; + var superview = customSuperView ?? view.GetContainerView(); if (superview is null) { view.ResignFirstResponder(); diff --git a/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs b/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs new file mode 100644 index 000000000000..c2e4f7a59e1f --- /dev/null +++ b/src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs @@ -0,0 +1,592 @@ +/* + * This class is adapted from IQKeyboardManager which is an open-source + * library implemented for iOS to handle Keyboard interactions with + * UITextFields/UITextViews. Link to their MIT License can be found here: + * https://github.com/hackiftekhar/IQKeyboardManager/blob/7399efb730eea084571b45a1a9b36a3a3c54c44f/LICENSE.md + */ + +using System; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using CoreGraphics; +using Foundation; +using UIKit; + +namespace Microsoft.Maui.Platform; + +internal static class KeyboardAutoManagerScroll +{ + internal static bool IsCurrentlyScrolling; + static UIScrollView? LastScrollView; + static CGPoint StartingContentOffset; + static UIEdgeInsets StartingScrollIndicatorInsets; + static UIEdgeInsets StartingContentInsets; + static CGRect KeyboardFrame = CGRect.Empty; + static CGPoint TopViewBeginOrigin = new(nfloat.MaxValue, nfloat.MaxValue); + static readonly CGPoint InvalidPoint = new(nfloat.MaxValue, nfloat.MaxValue); + static double AnimationDuration = 0.25; + static UIView? View = null; + static UIView? ContainerView = null; + static CGRect? CursorRect = null; + internal static bool IsKeyboardShowing = false; + static int TextViewTopDistance = 20; + static int DebounceCount = 0; + static NSObject? WillShowToken = null; + static NSObject? WillHideToken = null; + static NSObject? DidHideToken = null; + static NSObject? TextFieldToken = null; + static NSObject? TextViewToken = null; + + internal static void Connect() + { + if (TextFieldToken is not null) + return; + + TextFieldToken = NSNotificationCenter.DefaultCenter.AddObserver(new NSString("UITextFieldTextDidBeginEditingNotification"), DidUITextBeginEditing); + + TextViewToken = NSNotificationCenter.DefaultCenter.AddObserver(new NSString("UITextViewTextDidBeginEditingNotification"), DidUITextBeginEditing); + + WillShowToken = NSNotificationCenter.DefaultCenter.AddObserver(new NSString("UIKeyboardWillShowNotification"), WillKeyboardShow); + + WillHideToken = NSNotificationCenter.DefaultCenter.AddObserver(new NSString("UIKeyboardWillHideNotification"), WillHideKeyboard); + + DidHideToken = NSNotificationCenter.DefaultCenter.AddObserver(new NSString("UIKeyboardDidHideNotification"), DidHideKeyboard); + } + + internal static void Disconnect() + { + if (WillShowToken is not null) + NSNotificationCenter.DefaultCenter.RemoveObserver(WillShowToken); + if (WillHideToken is not null) + NSNotificationCenter.DefaultCenter.RemoveObserver(WillHideToken); + if (DidHideToken is not null) + NSNotificationCenter.DefaultCenter.RemoveObserver(DidHideToken); + if (TextFieldToken is not null) + NSNotificationCenter.DefaultCenter.RemoveObserver(TextFieldToken); + if (TextViewToken is not null) + NSNotificationCenter.DefaultCenter.RemoveObserver(TextViewToken); + + IsCurrentlyScrolling = false; + } + + static async void DidUITextBeginEditing(NSNotification notification) + { + IsCurrentlyScrolling = true; + + if (notification.Object is not null) + { + View = notification.Object as UIView; + + if (View is null) + return; + + CursorRect = null; + + ContainerView = View.GetContainerView(); + + // the cursor needs a small amount of time to update the position + await Task.Delay(5); + + var localCursor = FindLocalCursorPosition(); + if (localCursor is CGRect local) + CursorRect = View.ConvertRectToView(local, null); + + TextViewTopDistance = ((int?)localCursor?.Height ?? 0) + 20; + + await AdjustPositionDebounce(); + } + } + + static CGRect? FindLocalCursorPosition() + { + var textInput = View as IUITextInput; + var selectedTextRange = textInput?.SelectedTextRange; + return selectedTextRange is not null ? textInput?.GetCaretRectForPosition(selectedTextRange.Start) : null; + } + + internal static CGRect? FindCursorPosition() + { + var localCursor = FindLocalCursorPosition(); + if (localCursor is CGRect local) + return View?.ConvertRectToView(local, null); + + return null; + } + + static async void WillKeyboardShow(NSNotification notification) + { + var userInfo = notification.UserInfo; + + if (userInfo is not null) + { + var frameSize = userInfo.FindValue("UIKeyboardFrameEndUserInfoKey"); + var frameSizeRect = DescriptionToCGRect(frameSize?.Description); + if (frameSizeRect is not null) + KeyboardFrame = (CGRect)frameSizeRect; + + userInfo.SetAnimationDuration(); + } + + if (!IsKeyboardShowing) + { + await AdjustPositionDebounce(); + IsKeyboardShowing = true; + } + } + + static void WillHideKeyboard(NSNotification notification) + { + notification.UserInfo?.SetAnimationDuration(); + + if (LastScrollView is not null) + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, AnimateHidingKeyboard, () => { }); + + if (IsKeyboardShowing) + RestorePosition(); + + IsKeyboardShowing = false; + View = null; + LastScrollView = null; + KeyboardFrame = CGRect.Empty; + StartingContentInsets = new UIEdgeInsets(); + StartingScrollIndicatorInsets = new UIEdgeInsets(); + StartingContentInsets = new UIEdgeInsets(); + } + + static void DidHideKeyboard(NSNotification notification) + { + IsCurrentlyScrolling = false; + } + + static NSObject? FindValue(this NSDictionary dict, string key) + { + using var keyName = new NSString(key); + var isFound = dict.TryGetValue(keyName, out var obj); + return obj; + } + + static void SetAnimationDuration(this NSDictionary dict) + { + var durationObj = dict.FindValue("UIKeyboardAnimationDurationUserInfoKey"); + var durationNum = (NSNumber)NSObject.FromObject(durationObj); + var num = (double)durationNum; + if (num != 0) + AnimationDuration = num; + } + + static void AnimateHidingKeyboard() + { + if (LastScrollView is not null && LastScrollView.ContentInset != StartingContentInsets) + { + LastScrollView.ContentInset = StartingContentInsets; + LastScrollView.ScrollIndicatorInsets = StartingScrollIndicatorInsets; + } + + var superScrollView = LastScrollView; + while (superScrollView is not null) + { + var contentSize = new CGSize(Math.Max(superScrollView.ContentSize.Width, superScrollView.Frame.Width), + Math.Max(superScrollView.ContentSize.Height, superScrollView.Frame.Height)); + + var minY = contentSize.Height - superScrollView.Frame.Height; + if (minY < superScrollView.ContentOffset.Y) + { + var newContentOffset = new CGPoint(superScrollView.ContentOffset.X, minY); + if (!superScrollView.ContentOffset.Equals(newContentOffset)) + { + if (View?.Superview is UIStackView) + superScrollView.SetContentOffset(newContentOffset, UIView.AnimationsEnabled); + else + superScrollView.ContentOffset = newContentOffset; + } + } + superScrollView = superScrollView.FindResponder(); + } + } + + // Used to get the numeric values from the UserInfo dictionary's NSObject value to CGRect. + // Doing manually since CGRectFromString is not yet bound + static CGRect? DescriptionToCGRect(string? description) + { + // example of passed in description: "NSRect: {{0, 586}, {430, 346}}" + + if (description is null) + return null; + + // remove everything except for numbers and commas + var temp = Regex.Replace(description, @"[^0-9,]", ""); + var dimensions = temp.Split(','); + + if (dimensions.Length == 4 + && nfloat.TryParse(dimensions[0], out var x) + && nfloat.TryParse(dimensions[1], out var y) + && nfloat.TryParse(dimensions[2], out var width) + && nfloat.TryParse(dimensions[3], out var height)) + { + return new CGRect(x, y, width, height); + } + + return null; + } + + // Used to debounce calls from different oberservers so we can be sure + // all the fields are updated before calling AdjustPostition() + internal static async Task AdjustPositionDebounce() + { + Interlocked.Increment(ref DebounceCount); + + var entranceCount = DebounceCount; + + await Task.Delay(10); + + if (entranceCount == DebounceCount) + AdjustPosition(); + } + + // main method to calculate and animate the scrolling + internal static void AdjustPosition() + { + if (View is not UITextField field && View is not UITextView) + return; + + if (ContainerView is null) + return; + + if (TopViewBeginOrigin == InvalidPoint) + TopViewBeginOrigin = new CGPoint(ContainerView.Frame.X, ContainerView.Frame.Y); + + var rootViewOrigin = new CGPoint(ContainerView.Frame.GetMinX(), ContainerView.Frame.GetMinY()); + var window = ContainerView.Window; + + var intersectRect = CGRect.Intersect(KeyboardFrame, window.Frame); + var kbSize = intersectRect == CGRect.Empty ? new CGSize(KeyboardFrame.Width, 0) : intersectRect.Size; + + nfloat statusBarHeight; + nfloat navigationBarAreaHeight; + + if (ContainerView.GetNavigationController() is UINavigationController navigationController) + { + navigationBarAreaHeight = navigationController.NavigationBar.Frame.GetMaxY(); + } + else + { + if (OperatingSystem.IsIOSVersionAtLeast(13, 0)) + statusBarHeight = window.WindowScene?.StatusBarManager?.StatusBarFrame.Height ?? 0; + else + statusBarHeight = UIApplication.SharedApplication.StatusBarFrame.Height; + + navigationBarAreaHeight = statusBarHeight; + } + + var topLayoutGuide = Math.Max(navigationBarAreaHeight, ContainerView.LayoutMargins.Top) + 5; + + var keyboardYPosition = window.Frame.Height - kbSize.Height - TextViewTopDistance; + + var viewRectInWindow = View.ConvertRectToView(View.Bounds, window); + + // readjust contentInset when the textView height is too large for the screen + var rootSuperViewFrameInWindow = window.Frame; + if (ContainerView.Superview is UIView v) + rootSuperViewFrameInWindow = v.ConvertRectToView(v.Bounds, window); + + if (CursorRect is null) + return; + + var cursorRect = (CGRect)CursorRect; + + nfloat cursorNotInViewScroll = 0; + nfloat move = 0; + bool cursorTooHigh = false; + bool cursorTooLow = false; + + if (cursorRect.Y >= viewRectInWindow.GetMaxY()) + { + cursorNotInViewScroll = viewRectInWindow.GetMaxY() - cursorRect.GetMaxY(); + move = cursorRect.Y - keyboardYPosition + cursorNotInViewScroll; + cursorTooLow = true; + } + + else if (cursorRect.Y < viewRectInWindow.GetMinY()) + { + cursorNotInViewScroll = viewRectInWindow.GetMinY() - cursorRect.Y; + move = cursorRect.Y - keyboardYPosition + cursorNotInViewScroll; + cursorTooHigh = true; + + // no need to move the screen down if we can already see the view + if (move < 0) + move = 0; + } + + else if (cursorRect.Y >= topLayoutGuide && cursorRect.Y < keyboardYPosition) + return; + + else if (cursorRect.Y > keyboardYPosition) + move = cursorRect.Y - keyboardYPosition; + + else if (cursorRect.Y <= topLayoutGuide) + move = cursorRect.Y - (nfloat)topLayoutGuide; + + // Find the next parent ScrollView that is scrollable + var superView = View.FindResponder(); + var superScrollView = FindParentScroll(superView); + + // This is the case when the keyboard is already showing and we click another editor/entry + if (LastScrollView is not null) + { + // if there is not a current superScrollView, restore LastScrollView + if (superScrollView is null) + { + if (LastScrollView.ContentInset != StartingContentInsets) + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, AnimateStartingLastScrollView, () => { }); + + if (!LastScrollView.ContentOffset.Equals(StartingContentOffset)) + { + if (View.FindResponder() is UIStackView) + LastScrollView.SetContentOffset(StartingContentOffset, UIView.AnimationsEnabled); + else + LastScrollView.ContentOffset = StartingContentOffset; + } + + StartingContentInsets = new UIEdgeInsets(); + StartingScrollIndicatorInsets = new UIEdgeInsets(); + StartingContentOffset = new CGPoint(0, 0); + LastScrollView = null; + } + } + + else if (superScrollView is not null) + { + LastScrollView = superScrollView; + StartingContentInsets = superScrollView.ContentInset; + StartingContentOffset = superScrollView.ContentOffset; + + StartingScrollIndicatorInsets = OperatingSystem.IsIOSVersionAtLeast(11, 1) ? + superScrollView.VerticalScrollIndicatorInsets : superScrollView.ScrollIndicatorInsets; + } + + // Calculate the move for the ScrollViews + if (LastScrollView is not null) + { + var lastView = View; + superScrollView = LastScrollView; + nfloat innerScrollValue = 0; + + while (superScrollView is not null) + { + var shouldContinue = false; + + if (move > 0) + shouldContinue = move > -superScrollView.ContentOffset.Y - superScrollView.ContentInset.Top; + + else if (superScrollView.FindResponder() is UITableView tableView) + { + shouldContinue = superScrollView.ContentOffset.Y > 0; + + if (shouldContinue && View.FindResponder() is UITableViewCell tableCell + && tableView.IndexPathForCell(tableCell) is NSIndexPath indexPath + && tableView.GetPreviousIndexPath(indexPath) is NSIndexPath previousIndexPath) + { + var previousCellRect = tableView.RectForRowAtIndexPath(previousIndexPath); + if (!previousCellRect.IsEmpty) + { + var previousCellRectInRootSuperview = tableView.ConvertRectToView(previousCellRect, ContainerView.Superview); + move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topLayoutGuide); + } + } + } + + else if (superScrollView.FindResponder() is UICollectionView collectionView) + { + shouldContinue = superScrollView.ContentOffset.Y > 0; + + if (shouldContinue && View.FindResponder() is UICollectionViewCell collectionCell + && collectionView.IndexPathForCell(collectionCell) is NSIndexPath indexPath + && collectionView.GetPreviousIndexPath(indexPath) is NSIndexPath previousIndexPath + && collectionView.GetLayoutAttributesForItem(previousIndexPath) is UICollectionViewLayoutAttributes attributes) + { + var previousCellRect = attributes.Frame; + + if (!previousCellRect.IsEmpty) + { + var previousCellRectInRootSuperview = collectionView.ConvertRectToView(previousCellRect, ContainerView.Superview); + move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topLayoutGuide); + } + } + } + + else + { + shouldContinue = !(innerScrollValue == 0 + && cursorRect.Y + cursorNotInViewScroll >= topLayoutGuide + && cursorRect.Y + cursorNotInViewScroll <= keyboardYPosition); + + if (cursorRect.Y - innerScrollValue < topLayoutGuide && !cursorTooHigh) + move = cursorRect.Y - innerScrollValue - (nfloat)topLayoutGuide; + else if (cursorRect.Y - innerScrollValue > keyboardYPosition && !cursorTooLow) + move = cursorRect.Y - innerScrollValue - keyboardYPosition; + } + + // Go up the hierarchy and look for other scrollViews until we reach the UIWindow + if (shouldContinue) + { + var tempScrollView = superScrollView.FindResponder(); + var nextScrollView = FindParentScroll(tempScrollView); + + var shouldOffsetY = superScrollView.ContentOffset.Y - Math.Min(superScrollView.ContentOffset.Y, -move); + + // the contentOffset.Y will change to shouldOffSetY so we can subtract the difference from the move + move -= (nfloat)(shouldOffsetY - superScrollView.ContentOffset.Y); + + var newContentOffset = new CGPoint(superScrollView.ContentOffset.X, shouldOffsetY); + + if (!superScrollView.ContentOffset.Equals(newContentOffset) || innerScrollValue != 0) + { + // if we can scroll the superScrollView and still not be above keyboard, pass scrolling to the parent + var superScrollViewRect = superScrollView.ConvertRectToView(superScrollView.Bounds, window); + + if (nextScrollView is null && superScrollViewRect.Y < keyboardYPosition) + { + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => + { + newContentOffset.Y += innerScrollValue; + innerScrollValue = 0; + + if (View.FindResponder() is not null) + superScrollView.SetContentOffset(newContentOffset, UIView.AnimationsEnabled); + else + superScrollView.ContentOffset = newContentOffset; + }, () => { }); + } + + else + { + // add the amount we would have moved to the next scroll value + innerScrollValue += newContentOffset.Y; + } + } + + lastView = superScrollView; + superScrollView = nextScrollView; + } + + else + { + // if we did not get to scroll all the way, add the value to move + move += innerScrollValue; + break; + } + } + + move += innerScrollValue; + } + + if (move >= 0) + { + rootViewOrigin.Y = (nfloat)Math.Max(rootViewOrigin.Y - move, Math.Min(0, -kbSize.Height - TextViewTopDistance)); + + if (ContainerView.Frame.X != rootViewOrigin.X || ContainerView.Frame.Y != rootViewOrigin.Y) + { + var rect = ContainerView.Frame; + rect.X = rootViewOrigin.X; + rect.Y = rootViewOrigin.Y; + + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => AnimateRootView(rect), () => { }); + } + } + + else + { + rootViewOrigin.Y -= move; + + if (ContainerView.Frame.X != rootViewOrigin.X || ContainerView.Frame.Y != rootViewOrigin.Y) + { + var rect = ContainerView.Frame; + rect.X = rootViewOrigin.X; + rect.Y = rootViewOrigin.Y; + + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => AnimateRootView(rect), () => { }); + } + } + } + + static void AnimateStartingLastScrollView() + { + if (LastScrollView is not null) + { + LastScrollView.ContentInset = StartingContentInsets; + LastScrollView.ScrollIndicatorInsets = StartingScrollIndicatorInsets; + } + } + + static void AnimateRootView(CGRect rect) + { + if (ContainerView is not null) + ContainerView.Frame = rect; + } + + static UIScrollView? FindParentScroll(UIScrollView? view) + { + while (view is not null) + { + if (view.ScrollEnabled) + return view; + + view = view.FindResponder(); + } + + return null; + } + + internal static nfloat FindKeyboardHeight() + { + if (ContainerView is null) + return 0; + + var window = ContainerView.Window; + var intersectRect = CGRect.Intersect(KeyboardFrame, window.Frame); + var kbSize = intersectRect == CGRect.Empty ? new CGSize(KeyboardFrame.Width, 0) : intersectRect.Size; + + return window.Frame.Height - kbSize.Height; + } + + static void RestorePosition() + { + if (ContainerView is not null + && (ContainerView.Frame.X != TopViewBeginOrigin.X || ContainerView.Frame.Y != TopViewBeginOrigin.Y) + && TopViewBeginOrigin != InvalidPoint) + { + var rect = ContainerView.Frame; + rect.X = TopViewBeginOrigin.X; + rect.Y = TopViewBeginOrigin.Y; + + UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () => AnimateRootView(rect), () => { }); + } + View = null; + ContainerView = null; + TopViewBeginOrigin = InvalidPoint; + CursorRect = null; + } + + static NSIndexPath? GetPreviousIndexPath(this UIScrollView scrollView, NSIndexPath indexPath) + { + var previousRow = indexPath.Row - 1; + var previousSection = indexPath.Section; + + if (previousRow < 0) + { + previousSection -= 1; + if (previousSection >= 0 && scrollView is UICollectionView collectionView) + previousRow = (int)(collectionView.NumberOfItemsInSection(previousSection) - 1); + else if (previousSection >= 0 && scrollView is UITableView tableView) + previousRow = (int)(tableView.NumberOfRowsInSection(previousSection) - 1); + else + return null; + } + + if (previousRow >= 0 && previousSection >= 0) + return NSIndexPath.FromRowSection(previousRow, previousSection); + else + return null; + } +} diff --git a/src/Core/src/Platform/iOS/MauiScrollView.cs b/src/Core/src/Platform/iOS/MauiScrollView.cs new file mode 100644 index 000000000000..fec0897446c0 --- /dev/null +++ b/src/Core/src/Platform/iOS/MauiScrollView.cs @@ -0,0 +1,22 @@ +using System; +using CoreGraphics; +using UIKit; + +namespace Microsoft.Maui.Platform +{ + public class MauiScrollView : UIScrollView + { + public MauiScrollView() + { + } + + // overriding this method so it does not automatically scroll large UITextFields + // while the KeyboardAutoManagerScroll is scrolling. + public override void ScrollRectToVisible(CGRect rect, bool animated) + { + if (!KeyboardAutoManagerScroll.IsCurrentlyScrolling) + base.ScrollRectToVisible(rect, animated); + } + } +} + diff --git a/src/Core/src/Platform/iOS/ViewExtensions.cs b/src/Core/src/Platform/iOS/ViewExtensions.cs index df30aa16b40b..1608fcffdcea 100644 --- a/src/Core/src/Platform/iOS/ViewExtensions.cs +++ b/src/Core/src/Platform/iOS/ViewExtensions.cs @@ -728,6 +728,35 @@ internal static void UpdateLayerBorder(this CoreAnimation.CALayer layer, IButton return null; } + internal static T? FindResponder(this UIViewController controller) where T : UIViewController + { + var nextResponder = controller.View as UIResponder; + while (nextResponder is not null) + { + nextResponder = nextResponder.NextResponder; + + if (nextResponder is T responder && responder != controller) + return responder; + } + return null; + } + + internal static T? FindTopController(this UIView view) where T : UIViewController + { + var bestController = view.FindResponder(); + var tempController = bestController; + + while (tempController is not null) + { + tempController = tempController.FindResponder(); + + if (tempController is not null) + bestController = tempController; + } + + return bestController; + } + internal static UIView? FindNextView(this UIView? view, UIView containerView, Func isValidType) { UIView? nextView = null; @@ -784,5 +813,20 @@ internal static void ChangeFocusedView(this UIView view, UIView? newView) else newView.BecomeFirstResponder(); } + + internal static UIView? GetContainerView(this UIView? startingPoint) + { + var rootView = startingPoint?.FindResponder()?.View; + + if (rootView is not null) + return rootView; + + var firstViewController = startingPoint?.FindTopController(); + + if (firstViewController?.ViewIfLoaded is not null) + return firstViewController.ViewIfLoaded.FindDescendantView(); + + return null; + } } } diff --git a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt index f1e88e5f732b..630c8aa078db 100644 --- a/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -4,6 +4,8 @@ Microsoft.Maui.Handlers.SwipeItemButton.FrameChanged -> System.EventHandler? Microsoft.Maui.Handlers.SwipeItemButton.SwipeItemButton() -> void Microsoft.Maui.Layouts.FlexBasis.Equals(Microsoft.Maui.Layouts.FlexBasis other) -> bool Microsoft.Maui.SizeRequest.Equals(Microsoft.Maui.SizeRequest other) -> bool +Microsoft.Maui.Platform.MauiScrollView +Microsoft.Maui.Platform.MauiScrollView.MauiScrollView() -> void override Microsoft.Maui.Handlers.SwipeItemButton.Frame.get -> CoreGraphics.CGRect override Microsoft.Maui.Handlers.SwipeItemButton.Frame.set -> void override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.ConnectHandler(UIKit.UIButton! platformView) -> void @@ -11,6 +13,7 @@ override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.DisconnectHandler(UIKi override Microsoft.Maui.Handlers.SwitchHandler.NeedsContainer.get -> bool override Microsoft.Maui.Layouts.FlexBasis.Equals(object? obj) -> bool override Microsoft.Maui.Layouts.FlexBasis.GetHashCode() -> int +override Microsoft.Maui.Platform.MauiScrollView.ScrollRectToVisible(CoreGraphics.CGRect rect, bool animated) -> void override Microsoft.Maui.Platform.MauiTextView.Font.get -> UIKit.UIFont? override Microsoft.Maui.Platform.MauiTextView.Font.set -> void override Microsoft.Maui.SizeRequest.Equals(object? obj) -> bool diff --git a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index f1e88e5f732b..630c8aa078db 100644 --- a/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/Core/src/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -4,6 +4,8 @@ Microsoft.Maui.Handlers.SwipeItemButton.FrameChanged -> System.EventHandler? Microsoft.Maui.Handlers.SwipeItemButton.SwipeItemButton() -> void Microsoft.Maui.Layouts.FlexBasis.Equals(Microsoft.Maui.Layouts.FlexBasis other) -> bool Microsoft.Maui.SizeRequest.Equals(Microsoft.Maui.SizeRequest other) -> bool +Microsoft.Maui.Platform.MauiScrollView +Microsoft.Maui.Platform.MauiScrollView.MauiScrollView() -> void override Microsoft.Maui.Handlers.SwipeItemButton.Frame.get -> CoreGraphics.CGRect override Microsoft.Maui.Handlers.SwipeItemButton.Frame.set -> void override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.ConnectHandler(UIKit.UIButton! platformView) -> void @@ -11,6 +13,7 @@ override Microsoft.Maui.Handlers.SwipeItemMenuItemHandler.DisconnectHandler(UIKi override Microsoft.Maui.Handlers.SwitchHandler.NeedsContainer.get -> bool override Microsoft.Maui.Layouts.FlexBasis.Equals(object? obj) -> bool override Microsoft.Maui.Layouts.FlexBasis.GetHashCode() -> int +override Microsoft.Maui.Platform.MauiScrollView.ScrollRectToVisible(CoreGraphics.CGRect rect, bool animated) -> void override Microsoft.Maui.Platform.MauiTextView.Font.get -> UIKit.UIFont? override Microsoft.Maui.Platform.MauiTextView.Font.set -> void override Microsoft.Maui.SizeRequest.Equals(object? obj) -> bool diff --git a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs index eecf49a86259..cbb2e16d0161 100644 --- a/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs +++ b/src/Core/tests/DeviceTests/Handlers/Entry/EntryHandlerTests.iOS.cs @@ -1,5 +1,7 @@ using System; +using System.Threading; using System.Threading.Tasks; +using CoreGraphics; using Foundation; using Microsoft.Extensions.DependencyInjection; using Microsoft.Maui.DeviceTests.Stubs; @@ -480,6 +482,153 @@ await contentViewHandler.PlatformView.AttachAndRun(() => }); } + [Fact] + public async Task ScrollEntry() + { + var entry1 = new EntryStub + { + Height = 600 + }; + + var entry2 = new EntryStub + { + Height = 200 + }; + + await ScrollHelper(async () => await ScrollToText(entry2), entry1, entry2); + } + + [Fact] + public async Task ScrollEditor() + { + var entry = new EntryStub + { + Height = 600 + }; + + var editor = new EditorStub + { + Height = 200 + }; + + await ScrollHelper(async () => await ScrollToText(editor), entry, editor); + } + + [Fact] + public async Task ScrollNextEntry() + { + var entry1 = new EntryStub + { + Height = 600, + ReturnType = ReturnType.Next + }; + + var entry2 = new EntryStub + { + Height = 200 + }; + + await ScrollHelper(async () => await ScrollToNext(entry1, entry2), entry1, entry2); + } + + [Fact] + public async Task ScrollNextEditor() + { + var entry = new EntryStub + { + Height = 600, + ReturnType = ReturnType.Next + }; + + var editor = new EditorStub + { + Height = 200 + }; + + await ScrollHelper(async () => await ScrollToNext(entry, editor), entry, editor); + } + + async Task ScrollHelper(Func func, params StubBase[] views) + { + EnsureHandlerCreated(builder => + { + builder.ConfigureMauiHandlers(handler => + { + handler.AddHandler(); + handler.AddHandler(); + handler.AddHandler(); + }); + }); + + var layout = new VerticalStackLayoutStub(); + + foreach (var view in views) + { + layout.Add(view); + } + + layout.Width = 300; + layout.Height = 800; + + await InvokeOnMainThreadAsync(async () => + { + var contentViewHandler = CreateHandler(layout); + var contentPlatformView = contentViewHandler.PlatformView; + + await contentPlatformView.AttachAndRun(async () => await func.Invoke()); + }); + } + + async Task ScrollToText(StubBase selectedStub) + { + var uiTextField = selectedStub.ToPlatform(); + Assert.True(uiTextField.BecomeFirstResponder()); + + var isKeyboardShowing = await Wait(() => KeyboardAutoManagerScroll.IsKeyboardShowing, 1000); + + // on an iOS simulator that has softKeyboard toggled off, we will not see the keyboard + if (isKeyboardShowing) + { + var cursorRect = KeyboardAutoManagerScroll.FindCursorPosition(); + var keyboardHeight = KeyboardAutoManagerScroll.FindKeyboardHeight(); + + if (cursorRect is CGRect rect) + Assert.True(rect.Y < keyboardHeight, "cursor position"); + else + Assert.Fail("CursorRect should not be null"); + + uiTextField.ResignFirstResponder(); + await uiTextField.WaitForKeyboardToHide(); + } + } + + async Task ScrollToNext(StubBase originalStub, StubBase nextStub) + { + var originalUIText = originalStub.ToPlatform(); + var nextUIText = nextStub.ToPlatform(); + + KeyboardAutoManager.GoToNextResponderOrResign(originalUIText, customSuperView: originalUIText.Superview); + + Assert.True(nextUIText.BecomeFirstResponder()); + + var isKeyboardShowing = await Wait(() => KeyboardAutoManagerScroll.IsKeyboardShowing, 1000); + + // on an iOS simulator that has softKeyboard toggled off, we will not see the keyboard + if (isKeyboardShowing) + { + var cursorRect = KeyboardAutoManagerScroll.FindCursorPosition(); + var keyboardHeight = KeyboardAutoManagerScroll.FindKeyboardHeight(); + + if (cursorRect is CGRect rect) + Assert.True(rect.Y < keyboardHeight, "cursor position"); + else + Assert.Fail("CursorRect should not be null"); + + nextUIText.ResignFirstResponder(); + await nextUIText.WaitForKeyboardToHide(); + } + } + double GetNativeCharacterSpacing(EntryHandler entryHandler) { var entry = GetNativeEntry(entryHandler); diff --git a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs index bc4f04d5fa03..f9efad080a4a 100644 --- a/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs +++ b/src/TestUtils/src/DeviceTests/AssertionExtensions.iOS.cs @@ -13,14 +13,16 @@ namespace Microsoft.Maui.DeviceTests { public static partial class AssertionExtensions { - public static Task WaitForKeyboardToShow(this UIView view, int timeout = 1000) + public static async Task WaitForKeyboardToShow(this UIView view, int timeout = 1000) { - throw new NotImplementedException(); + var result = await Wait(() => KeyboardAutoManagerScroll.IsKeyboardShowing, timeout); + Assert.True(result); } - public static Task WaitForKeyboardToHide(this UIView view, int timeout = 1000) + public static async Task WaitForKeyboardToHide(this UIView view, int timeout = 1000) { - throw new NotImplementedException(); + var result = await Wait(() => !KeyboardAutoManagerScroll.IsKeyboardShowing, timeout); + Assert.True(result); } public static Task SendValueToKeyboard(this UIView view, char value, int timeout = 1000)