UWP Apps & the On-Screen Keyboard

It seems that I’m always surprised that Windows 10 can be used in tablet mode.  I mean, intellectually I know you can do it, but in practice I rarely do.  I suspect this is more tied to historical use patterns than anything else, and I suspect that a lot of (most?) users are in the same boat.

The problem that arises out of this blind spot though, is that we (okay, me) as developers don’t necessarily put the same kind of effort into good tablet operation as we might on a touch-first platform like Android or iOS.  So we (I) create a vicious circle  where apps don’t “do the right thing” in tablet mode, and users avoid it.

I ran into this a week or so ago when I pulled the keyboard off my Surface Go and started doing some editing in eclecdec.

What I expected to see (without really thinking about), was this:

(the height of the editor, in other words, being decreased to take into account the height of the on-screen keyboard)

What I actually saw was this:

(the entire page shifted up)

Ooops.

So.  It turns out (quite intelligently, I think), that walking up the visual tree from a Page yields a ScrollViewer.  If no other action is taken when the on-screen keyboard occludes the view, the entire Page is scrolled up.  Makes sense.  A lot of the time, this is the right (or at least, a not bad) thing to do.  Much better than a default of “just hide what’s under the keyboard”, for those of us (see above) for whom tablet mode was an afterthought.

Not quite what I wanted, though, given that there’s some useful stuff that’s only accessible from my ribbon.

Some background and caveats before we get started.

In eclecdec, I have an Editor user control that contains a ribbon and editing surface.  I use this control both in a full Page, and in a dialog.  It takes up all of the free space to do its thing.  So what I’m trying to fix here is the Editor being hosted in a Page, not in one of my dialogs.  YMMV if you’re doing something different.  Also, I have to believe that there’s an easier way of doing this.  Just sayin’

Anyway, first things first.

It’s easy to find out when the on-screen keyboard has occluded (or stopped occluding) your view, using the CoreInputView class.  You can add an event handler that’s called whenever the OcclusionsChanged event is fired:

if (UIViewSettings.GetForCurrentView().UserInteractionMode == UserInteractionMode.Touch)
{
    CoreInputView civ = CoreInputView.GetForCurrentView();
    civ.OcclusionsChanged += this.CivOnOcclusionsChanged;
}

What I’m going to do is allow my Editor control (which, of course, contains its own ScrollViewer for the text editor), to do the right thing and simply reduce the height of the text editor when the on-screen keyboard appears.  To allow this to happen, I adjusted the Grid layout to add a row that I can set the height of (and allow the editor to resize).  Something like this:

<Grid x:Name="MainGrid" Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
    <Grid.RowDefinitions>
        <RowDefinition Height="92"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="0"/>
    </Grid.RowDefinitions>
    <sfRibbon:SfRibbon Grid.Row="0" ... 
    <Grid x:Name="EditorGrid" Grid.Row="1" ...
    <Grid x:Name="InputPanelGrid" Grid.Row="2" ...
</Grid>

So if I increase the height of the third row (causing it to be the same height as the input panel), the second row (containing the scrollable editor) will resize appropriately.  The event handler for that OcclusionsChanged event looks like this:

private double panelHeight;

private void CivOnOcclusionsChanged(CoreInputView sender, CoreInputViewOcclusionsChangedEventArgs args)
{
    CoreInputViewOcclusion occlusion = (from o in args.Occlusions
            where o.OcclusionKind == CoreInputViewOcclusionKind.Docked
            select o).FirstOrDefault();

    if (occlusion != null)
    {
        this.panelHeight = occlusion.OccludingRect.Height;
        this.MainGrid.RowDefinitions[2] = new RowDefinition { Height = new GridLength(this.panelHeight) };
    }
    else if (this.panelHeight > 0)
    {
        this.panelHeight = 0;
        this.MainGrid.RowDefinitions[2] = new RowDefinition { Height = new GridLength(this.panelHeight) };
    }
}

What I’m doing here, mainly, is taking action on a Docked event.  In other words, the on-screen keyboard is docked and therefore occluding some rectangle (I’m ignoring the floating keyboard because the user can move it themselves).

On a Docked event, I set the height of that third Grid row to the height of the keyboard panel.  The UI reflows perfectly, and the height of the text editor is set to the space between the bottom of the ribbon and the top of the keyboard, with its own ScrollViewer doing the right thing.

When the keyboard is dismissed (or the keyboard type is changed), I simply set the height of the third Grid row back to zero.  I keep the panel height squirreled away so I know when the keyboard is dismissed.

This solves one problem.

However, because my Editor control is placed on a Page, there’s still that ScrollViewer sitting there up the visual tree that can interfere with my precise positioning.

So in the Page that’s hosting the Editor control, I reach out and deliberately turn off its scrolling ability (very carefully, I might add, and carefully putting it back the way it was, although this is undoubtedly superstition and overkill):

private ScrollMode savedHorizontalScrollMode;
private ScrollMode savedVerticalScrollMode;
private ScrollBarVisibility savedHorizontalScrollBarVisibility;
private ScrollBarVisibility savedVerticalScrollBarVisibility;

...

/// <summary>
/// Invoked when the page is navigated from.
/// </summary>
/// <param name="e">The event args.</param>
protected override void OnNavigatedFrom(NavigationEventArgs e)
{
    // Only fiddling with the parent scroll view for touch
    if (UIViewSettings.GetForCurrentView().UserInteractionMode == UserInteractionMode.Touch)
    {
        // We have access to the visual tree at this point
        var parent = Helpers.Helpers.GetRootScrollViewer(this);

        if (parent != null)
        {
            parent.HorizontalScrollMode = this.savedHorizontalScrollMode;
            parent.VerticalScrollMode = this.savedVerticalScrollMode;
            parent.HorizontalScrollBarVisibility = this.savedHorizontalScrollBarVisibility;
            parent.VerticalScrollBarVisibility = this.savedVerticalScrollBarVisibility;
        }
    }
}

/// <summary>
/// Invoked when the page is loaded. Turn off scrolling on the root scroll viewer so we
/// can handle our own layout when the onscreen keyboard is used.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private void OnLoaded(object sender, RoutedEventArgs e)
{
    // Only fiddling with the parent scroll view for touch
    if (UIViewSettings.GetForCurrentView().UserInteractionMode == UserInteractionMode.Touch)
    {
        // We have access to the visual tree at this point
        var parent = Helpers.Helpers.GetRootScrollViewer(this);

        if (parent != null)
        {
            this.savedHorizontalScrollMode = parent.HorizontalScrollMode;
            this.savedVerticalScrollMode = parent.VerticalScrollMode;
            this.savedHorizontalScrollBarVisibility = parent.HorizontalScrollBarVisibility;
            this.savedVerticalScrollBarVisibility = parent.VerticalScrollBarVisibility;

            parent.HorizontalScrollMode = ScrollMode.Disabled;
            parent.VerticalScrollMode = ScrollMode.Disabled;
            parent.HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled;
            parent.VerticalScrollBarVisibility = ScrollBarVisibility.Disabled;
        }
    }
}

Of course, there’s a method as well that tries to find that root scroll viewer:

public static ScrollViewer GetRootScrollViewer(DependencyObject el)
{
    while (el != null && !(el is ScrollViewer))
    {
        el = VisualTreeHelper.GetParent(el);
    }

    return (ScrollViewer)el;
}

With this in place, the root ScrollViewer no longer scrolls the Page view up, so all positioning happens in the Editor control.

And voila.

As I mentioned earlier, there must be an easier way of doing this (particularly the last part, which feels kind of fragile).  If and when I find it, I’ll report back.  In the meantime, I’m happily using eclecdec in tablet mode.

Leave a Reply

Your email address will not be published. Required fields are marked *