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

[iOS] Adjust Keyboard Scrolling for Sticky Headers and Fix Bottom Content Inset #20562

Merged
merged 4 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Maui.Controls.Sample.Issues.Issue19956"
Title="Issue19956">

<Grid RowDefinitions="Auto,*">
<VerticalStackLayout
Grid.Row="0"
BackgroundColor="DarkOrange">
<Label
Margin="30"
HorizontalOptions="Center"
HorizontalTextAlignment="Center"
Text="Sticky header panel"
VerticalOptions="Center"
AutomationId="StickyHeader" />
</VerticalStackLayout>
<ScrollView Grid.Row="1">
<VerticalStackLayout
Padding="30,0"
Spacing="25">
<Label
Text="1" />
<Entry ReturnType="Next" BackgroundColor="WhiteSmoke" AutomationId="Entry1" />
<Label
Text="2" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry2" />
<Label
Text="3" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry3"/>
<Label
Text="4" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry4"/>
<Label
Text="5" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry5"/>
<Label
Text="6" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry6"/>
<Label
Text="7" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry7"/>
<Label
Text="8" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry8"/>
<Label
Text="9" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry9"/>
<Label
Text="10" />
<Entry ReturnType="Next" BackgroundColor="WhiteSmoke" AutomationId="Entry10"/>
<Label
Text="11" />
<Entry BackgroundColor="WhiteSmoke" AutomationId="Entry11"/>
<Label
Text="12" />
<Entry ReturnType="Next" BackgroundColor="WhiteSmoke" AutomationId="Entry12"/>
</VerticalStackLayout>
</ScrollView>
</Grid>
</ContentPage>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.ComponentModel;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Xaml;

namespace Maui.Controls.Sample.Issues
{
[XamlCompilation(XamlCompilationOptions.Compile)]
[Issue(IssueTracker.Github, 19956, "Sticky headers and bottom content insets", PlatformAffected.iOS)]
public partial class Issue19956 : ContentPage
{
public Issue19956()
{
InitializeComponent();
}
}
}
93 changes: 93 additions & 0 deletions src/Controls/tests/UITests/Tests/Issues/Issue19956.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Drawing;
using NUnit.Framework;
using UITest.Appium;
using UITest.Core;
using Microsoft.Maui.AppiumTests;
using OpenQA.Selenium.Appium.MultiTouch;

namespace Microsoft.Maui.AppiumTests.Issues;
public class Issue19956: _IssuesUITest
{
public Issue19956(TestDevice device) : base(device) { }

public override string Issue => "Sticky headers and bottom content insets";

[Test]
public void ContentAccountsForStickyHeaders()
{
this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Android, TestDevice.Mac, TestDevice.Windows },
"This is an iOS Keyboard Scrolling issue.");

var app = App as AppiumApp;
if (app is null)
return;

var stickyHeader = App.WaitForElement("StickyHeader");
var stickyHeaderRect = stickyHeader.GetRect();

// Scroll to the bottom of the page
var actions = new TouchAction(app.Driver);
actions.LongPress(null, 5, 650).MoveTo(null, 5, 100).Release().Perform();

app.Click("Entry12");
ValidateEntryPosition("Entry12", app, stickyHeaderRect);
ValidateEntryPosition("Entry1", app, stickyHeaderRect);
ValidateEntryPosition("Entry2", app, stickyHeaderRect);
}

void ValidateEntryPosition (string entryName, AppiumApp app, Rectangle stickyHeaderRect)
{
var entryRect = App.WaitForElement(entryName).GetRect();
var keyboardPos = KeyboardScrolling.FindiOSKeyboardLocation(app.Driver);

Assert.AreEqual(App.WaitForElement("StickyHeader").GetRect(), stickyHeaderRect);
Assert.Less(stickyHeaderRect.Bottom, entryRect.Top);
Assert.NotNull(keyboardPos);
Assert.Less(entryRect.Bottom, keyboardPos!.Value.Y);

KeyboardScrolling.NextiOSKeyboardPress(app.Driver);
}

[Test]
public void BottomInsetsSetCorrectly()
{
this.IgnoreIfPlatforms(new TestDevice[] { TestDevice.Android, TestDevice.Mac, TestDevice.Windows },
"This is an iOS Keyboard Scrolling issue.");

var app = App as AppiumApp;
if (app is null)
return;

app.Click("Entry5");
ScrollToBottom(app);
CheckForBottomEntry(app);
KeyboardScrolling.NextiOSKeyboardPress(app.Driver);

app.Click("Entry10");
ScrollToBottom(app);
CheckForBottomEntry(app);
KeyboardScrolling.NextiOSKeyboardPress(app.Driver);

ScrollToBottom(app);
CheckForBottomEntry(app);
}

static void ScrollToBottom(AppiumApp app)
{
var actions = new TouchAction(app.Driver);
// scroll up once to trigger resetting content insets
actions.LongPress(null, 5, 300).MoveTo(null, 5, 450).Release().Perform();
actions.LongPress(null, 5, 400).MoveTo(null, 5, 100).Release().Perform();

actions.LongPress(null, 5, 400).MoveTo(null, 5, 100).Release().Perform();
actions.LongPress(null, 5, 400).MoveTo(null, 5, 100).Release().Perform();
}

void CheckForBottomEntry (AppiumApp app)
{
var bottomEntryRect = App.WaitForElement("Entry12").GetRect();
var keyboardPosition = KeyboardScrolling.FindiOSKeyboardLocation(app.Driver);
Assert.NotNull(keyboardPosition);
Assert.Less(bottomEntryRect.Bottom, keyboardPosition!.Value.Y);
}
}
74 changes: 41 additions & 33 deletions src/Core/src/Platform/iOS/KeyboardAutoManagerScroll.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ public static class KeyboardAutoManagerScroll
static CGRect? CursorRect;
static CGRect? StartingContainerViewFrame;
internal static bool IsKeyboardShowing;
static int TextViewTopDistance = 20;
static int TextViewDistanceFromBottom = 20;
static int TextViewDistanceFromTop = 5;
static int DebounceCount;
static NSObject? WillShowToken;
static NSObject? WillHideToken;
Expand Down Expand Up @@ -316,7 +317,7 @@ internal static void AdjustPosition()
nfloat statusBarHeight;
nfloat navigationBarAreaHeight;

if (ContainerView.GetNavigationController() is UINavigationController navigationController)
if (ContainerView.FindResponder<UINavigationController>() is UINavigationController navigationController)
{
navigationBarAreaHeight = navigationController.NavigationBar.Frame.GetMaxY();
}
Expand All @@ -330,7 +331,7 @@ internal static void AdjustPosition()
navigationBarAreaHeight = statusBarHeight;
}

var topLayoutGuide = Math.Max(navigationBarAreaHeight, ContainerView.LayoutMargins.Top) + 5;
var topLayoutGuide = Math.Max(navigationBarAreaHeight, ContainerView.LayoutMargins.Top) + TextViewDistanceFromTop;

// calculate the cursor rect
var localCursor = FindLocalCursorPosition();
Expand All @@ -347,9 +348,9 @@ internal static void AdjustPosition()

// give a small offset of 20 plus the cursor.Height for the distance
// between the selected text and the keyboard
TextViewTopDistance = ((int?)localCursor?.Height ?? 0) + 20;
TextViewDistanceFromBottom = ((int?)localCursor?.Height ?? 0) + 20;

var keyboardYPosition = window.Frame.Height - kbSize.Height - TextViewTopDistance;
var keyboardYPosition = window.Frame.Height - kbSize.Height - TextViewDistanceFromBottom;

// readjust contentInset when the textView height is too large for the screen
var rootSuperViewFrameInWindow = window.Frame;
Expand All @@ -363,12 +364,26 @@ internal static void AdjustPosition()
bool cursorTooHigh = false;
bool cursorTooLow = false;

// Find the next parent ScrollView that is scrollable
var superView = View.FindResponder<UIScrollView>();
var superScrollView = FindParentScroll(superView);

CGRect? superScrollViewRect = null;
var topBoundary = topLayoutGuide;
var bottomBoundary = (double)keyboardYPosition;

if (superScrollView is not null){
superScrollViewRect = superScrollView.ConvertRectToView(superScrollView.Bounds, window);
topBoundary = Math.Max(topBoundary, superScrollViewRect.Value.Top + TextViewDistanceFromTop);
bottomBoundary = Math.Min(bottomBoundary, superScrollViewRect.Value.Bottom - TextViewDistanceFromBottom);
}

// scenario where we go into an editor with the "Next" keyboard button,
// but the cursor location on the editor is scrolled below the visible section
if (View is UITextView && cursorRect.Y >= viewRectInWindow.GetMaxY())
{
cursorNotInViewScroll = viewRectInWindow.GetMaxY() - cursorRect.GetMaxY();
move = cursorRect.Y - keyboardYPosition + cursorNotInViewScroll;
move = cursorRect.Y - (nfloat)bottomBoundary + cursorNotInViewScroll;
cursorTooLow = true;
}

Expand All @@ -377,26 +392,22 @@ internal static void AdjustPosition()
else if (View is UITextView && cursorRect.Y < viewRectInWindow.GetMinY())
{
cursorNotInViewScroll = viewRectInWindow.GetMinY() - cursorRect.Y;
move = cursorRect.Y - keyboardYPosition + cursorNotInViewScroll;
move = cursorRect.Y - (nfloat)bottomBoundary + 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)
else if (cursorRect.Y >= topBoundary && cursorRect.Y < bottomBoundary)
return;

else if (cursorRect.Y > keyboardYPosition)
move = cursorRect.Y - keyboardYPosition;

else if (cursorRect.Y <= topLayoutGuide)
move = cursorRect.Y - (nfloat)topLayoutGuide;
else if (cursorRect.Y > bottomBoundary)
move = cursorRect.Y - (nfloat)bottomBoundary;

// Find the next parent ScrollView that is scrollable
var superView = View.FindResponder<UIScrollView>();
var superScrollView = FindParentScroll(superView);
else if (cursorRect.Y <= topBoundary)
move = cursorRect.Y - (nfloat)topBoundary;

// This is the case when the keyboard is already showing and we click another editor/entry
if (LastScrollView is not null)
Expand Down Expand Up @@ -458,7 +469,7 @@ internal static void AdjustPosition()
if (!previousCellRect.IsEmpty)
{
var previousCellRectInRootSuperview = tableView.ConvertRectToView(previousCellRect, ContainerView.Superview);
move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topLayoutGuide);
move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topBoundary);
}
}
}
Expand All @@ -477,21 +488,21 @@ internal static void AdjustPosition()
if (!previousCellRect.IsEmpty)
{
var previousCellRectInRootSuperview = collectionView.ConvertRectToView(previousCellRect, ContainerView.Superview);
move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topLayoutGuide);
move = (nfloat)Math.Min(0, previousCellRectInRootSuperview.GetMaxY() - topBoundary);
}
}
}

else
{
shouldContinue = !(innerScrollValue == 0
&& cursorRect.Y + cursorNotInViewScroll >= topLayoutGuide
&& cursorRect.Y + cursorNotInViewScroll <= keyboardYPosition);
&& cursorRect.Y + cursorNotInViewScroll >= topBoundary
&& cursorRect.Y + cursorNotInViewScroll <= bottomBoundary);

if (cursorRect.Y - innerScrollValue < topLayoutGuide && !cursorTooHigh)
move = cursorRect.Y - innerScrollValue - (nfloat)topLayoutGuide;
else if (cursorRect.Y - innerScrollValue > keyboardYPosition && !cursorTooLow)
move = cursorRect.Y - innerScrollValue - keyboardYPosition;
if (cursorRect.Y - innerScrollValue < topBoundary && !cursorTooHigh)
move = cursorRect.Y - innerScrollValue - (nfloat)topBoundary;
else if (cursorRect.Y - innerScrollValue > bottomBoundary && !cursorTooLow)
move = cursorRect.Y - innerScrollValue - (nfloat)bottomBoundary;
}

// Go up the hierarchy and look for other scrollViews until we reach the UIWindow
Expand All @@ -502,7 +513,7 @@ internal static void AdjustPosition()

// if PrefersLargeTitles is true, we may need additional logic to
// handle the collapsable navbar
var navController = View?.GetNavigationController();
var navController = View?.FindResponder<UINavigationController>();
var prefersLargeTitles = navController?.NavigationBar.PrefersLargeTitles ?? false;

if (prefersLargeTitles)
Expand All @@ -517,12 +528,9 @@ internal static void AdjustPosition()

var newContentOffset = new CGPoint(superScrollView.ContentOffset.X, shouldOffsetY);

if (!superScrollView.ContentOffset.Equals(newContentOffset) || innerScrollValue != 0)
if ((!superScrollView.ContentOffset.Equals(newContentOffset) || innerScrollValue != 0) && superScrollViewRect is not null)
{
// 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)
if (nextScrollView is null && superScrollViewRect.Value.Y < bottomBoundary)
{
UIView.Animate(AnimationDuration, 0, UIViewAnimationOptions.CurveEaseOut, () =>
{
Expand Down Expand Up @@ -569,8 +577,8 @@ internal static void AdjustPosition()
// ContentInset logic
if (ScrolledView is not null)
{
var bottomInset = ScrolledView.Bounds.Height + ScrolledView.ContentOffset.Y - ScrolledView.ContentSize.Height;
var bottomScrollIndicatorInset = bottomInset - TextViewTopDistance;
var bottomInset = kbSize.Height;
var bottomScrollIndicatorInset = bottomInset - TextViewDistanceFromBottom;

bottomInset = nfloat.Max(StartingContentInsets.Bottom, bottomInset);
bottomScrollIndicatorInset = nfloat.Max(StartingScrollIndicatorInsets.Bottom, bottomScrollIndicatorInset);
Expand All @@ -591,7 +599,7 @@ internal static void AdjustPosition()

if (move >= 0)
{
rootViewOrigin.Y = (nfloat)Math.Max(rootViewOrigin.Y - move, Math.Min(0, -kbSize.Height - TextViewTopDistance));
rootViewOrigin.Y = (nfloat)Math.Max(rootViewOrigin.Y - move, Math.Min(0, -kbSize.Height - TextViewDistanceFromBottom));

if (ContainerView.Frame.X != rootViewOrigin.X || ContainerView.Frame.Y != rootViewOrigin.Y)
{
Expand Down
Loading