Skip to content

Glide dialogs out of the way of the keyboard - #114

Merged
danielchalmers merged 1 commit into
mainfrom
claude/keyboard-css-animations-867787
Jul 25, 2026
Merged

Glide dialogs out of the way of the keyboard#114
danielchalmers merged 1 commit into
mainfrom
claude/keyboard-css-animations-867787

Conversation

@danielchalmers

Copy link
Copy Markdown
Owner

Fixes #113

Problem

Opening the soft keyboard resized the page in one unanimated step, so an open dialog snapped to its new position instead of moving smoothly. Attempts to animate around the resize always produced a bounce because two systems were moving the layout at once.

Root cause

SafeAreaEdges="All" includes the soft input region in .NET 10, so MAUI padded the page for the keyboard in a single step (the WindowSoftInputModeAdjust="Resize" platform specific was redundant with it). On top of that, MAUI hands the nav bar letterbox to the WebView partway through the keyboard animation and takes it back after it closes, so any keyboard math based on window insets goes stale mid-flight.

Fix

Let the keyboard overlay the WebView and have the web layer dodge it, driven by the one signal that stays correct through every native layout change: Chromium's own window.visualViewport.

  • SafeAreaEdges="Container" keeps the system bar letterbox but no longer pads for the keyboard, and Android 11+ uses AdjustNothing so the window stays static
  • A small script in index.html mirrors visualViewport.height into CSS variables
  • .mud-dialog-container is pinned to the visible height with a 250ms Material transition, so dialogs glide clear of the keyboard and glide back when it closes
  • body gains scroll room equal to the keyboard overlap so page content can still be scrolled out from under it
  • Android 10 and older WebViews don't track the ime in the visual viewport, so they keep the legacy resize behavior

No native listeners or insets plumbing needed, and the same mechanism works in regular browsers, which makes it a candidate to propose upstream in MudBlazor.

Known limitation

When the keyboard closes, MAUI restores the nav bar letterbox about 250ms later, which retargets the tail of the dialog's return glide. It reads as one continuous motion rather than a bounce, but it is not perfectly seamless.

The keyboard now overlays the WebView instead of resizing it, and the web layer tracks window.visualViewport to move dialogs clear of it with a smooth transition.
@danielchalmers

Copy link
Copy Markdown
Owner Author

Before

Screen_Recording_20260725_161642.mp4

After

Screen_Recording_20260725_161820.mp4

@danielchalmers
danielchalmers merged commit 02bef47 into main Jul 25, 2026
1 check passed
@danielchalmers
danielchalmers deleted the claude/keyboard-css-animations-867787 branch July 25, 2026 21:27
@danielchalmers

Copy link
Copy Markdown
Owner Author

Follow-up: this regressed keyboard avoidance for plain in-page fields. Tapping something like Weight on the timeline now lets the keyboard cover the field, where before the page shifted up so you could still see it.

Cause: the old WindowSoftInputModeAdjust="Resize" shrank the layout viewport, and that resize is what made the WebView auto-scroll the focused element into view. With AdjustNothing only the visual viewport shrinks, and Chromium does no auto-scroll for that. This PR pins .mud-dialog-container to --visible-height, so dialogs dodge, but nothing moves ordinary page content.

Candidate fix, staying in the web layer off the visualViewport signal this PR already wires up:

const focusMargin = 16;

const takesKeyboard = (element) =>
    element && (element.isContentEditable || element.tagName === 'TEXTAREA' ||
        (element.tagName === 'INPUT' && !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/.test(element.type)));

const scrollerFor = (element) => {
    for (let node = element.parentElement; node; node = node.parentElement) {
        const style = getComputedStyle(node);

        if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight)
            return node;

        // Dialogs and popovers are fixed, so the document scroll can't move them and only an inner scroller would help.
        if (style.position === 'fixed')
            return null;
    }

    return document.scrollingElement;
};

const keepFocusVisible = () => {
    const element = document.activeElement;
    if (!takesKeyboard(element))
        return;

    const rect = element.getBoundingClientRect();
    const top = viewport.offsetTop + focusMargin;
    const bottom = viewport.offsetTop + viewport.height - focusMargin;
    // Move as little as possible, and never so far that a tall field's top slides off the other edge.
    const delta = rect.bottom > bottom
        ? Math.max(0, Math.min(rect.bottom - bottom, rect.top - top))
        : Math.min(0, Math.max(rect.top - top, rect.bottom - bottom));
    if (Math.abs(delta) < 1)
        return;

    scrollerFor(element)?.scrollBy({ top: delta, behavior: 'smooth' });
};

// The viewport can resize several times while the keyboard animates in, so only chase the field once it settles.
let focusTimer;
const scheduleFocusCheck = () => {
    clearTimeout(focusTimer);
    focusTimer = setTimeout(keepFocusVisible, 150);
};

viewport.addEventListener('resize', () => {
    applyViewport();
    scheduleFocusCheck();
});

// Tapping another field while the keyboard is already up resizes nothing, so watch focus as well.
document.addEventListener('focusin', scheduleFocusCheck);

Notes on the shape of it:

  • The focusin listener matters because tapping a second field while the keyboard is already up resizes nothing, so the resize path alone would miss it.
  • The debounce is there because the visual viewport resizes several times per keyboard animation. Without it the overlapping smooth scrolls stack and overshoot.
  • Bailing out at a position: fixed ancestor stops it from scrolling the page behind a dialog, while a field inside a dialog's own scroller still gets handled. Dialogs keep dodging via the container height from this PR.
  • body { padding-bottom: var(--keyboard-inset) } from this PR is already the scroll room this needs for a field at the very bottom of the page.
  • On API < 30 the AdjustResize fallback still scrolls natively first, so the measured delta is zero and this stays a no-op.

I checked the math and the ancestor walk against a real DOM with a faked keyboard-shrunken viewport: a field at y=900 with 400px visible scrolls the document 578px so its bottom lands at the margin, an already-visible field scrolls 0, a 700px tall field aligns its top instead of overshooting, a field in a fixed dialog returns no scroller, and one inside an overflow-y: auto container scrolls that container. Not tried on a device yet, so the feel of the glide and how it lines up with the 250ms dialog transition are both unverified.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dialogs jump when the Android keyboard opens

1 participant