Glide dialogs out of the way of the keyboard - #114
Conversation
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.
|
Before Screen_Recording_20260725_161642.mp4After Screen_Recording_20260725_161820.mp4 |
|
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 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:
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 |
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 (theWindowSoftInputModeAdjust="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+ usesAdjustNothingso the window stays staticindex.htmlmirrorsvisualViewport.heightinto CSS variables.mud-dialog-containeris pinned to the visible height with a 250ms Material transition, so dialogs glide clear of the keyboard and glide back when it closesbodygains scroll room equal to the keyboard overlap so page content can still be scrolled out from under itNo 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.