DEV Community

Cover image for Flutter: A not so simple login form - Keyboard adaptive layout with RenderShiftedBox
Bibek Panda
Bibek Panda

Posted on • Originally published at Medium

Flutter: A not so simple login form - Keyboard adaptive layout with RenderShiftedBox

Why every obvious fix fails, what the keyboard really is under the hood, and the 20-line render box that makes the screen move with it.

The short version: Set resizeToAvoidBottomInset: false, read the keyboard
height from View.of(context).viewInsets.bottom / devicePixelRatio inside
didChangeMetrics, pad your body's bottom by that height, and shrink the hero
with a small RenderShiftedBox. Full code below; runnable repo at the end.


Every screen I build starts the same way: the design file open on one side, an empty Dart file on the other.

The login screen was supposed to be the easy one. A large hero illustration at the top, a phone number field in the middle, a "Get OTP" button anchored at the bottom. I spent the better part of a day getting it pixel-perfect — the illustration centered just right, the pill-shaped input, the button stretched edge to edge with exactly the spacing the design called for. I ran it on the simulator, leaned back, and admired it. This was a good-looking screen.

Then I did the one thing I hadn't done yet: I tapped the phone number field.

The keyboard slid up — and took my button with it. The primary CTA, the single control this entire screen exists to serve, was buried beneath the number pad. My beautiful layout hadn't broken, exactly; it just hadn't been designed for the moment a keyboard claims a third of the screen.

One tap. Two weeks. This article covers everything I learned working through the problem — and how a seemingly ordinary login form turned into one of the most interesting layout challenges I've encountered in Flutter.

[IMAGE PLACEHOLDER 1 — hero GIF, side by side: "before" (button buried under keyboard) vs "after" (image shrinks, button rides the keyboard up). This is the social preview card — put it here, above the fold.]


The screen

Nothing exotic. Two screens, actually — login and OTP verification — with the same skeleton (that "two screens" detail pays off later):

┌──────────────────┐
│   [illustration] │   ← big SVG hero, ~400px tall
│                  │
│   Login with     │
│   Phone number   │
│   [+91 ______ ]  │
│   ☐ I agree...   │
│                  │
│   [  Get OTP  ]  │   ← pinned to the bottom
└──────────────────┘
Enter fullscreen mode Exit fullscreen mode

The requirement was equally simple to state: when the keyboard opens, the illustration should slide up out of the way — by exactly the keyboard's height — so the button ends up sitting right on top of the keyboard. When the keyboard closes, everything slides back. Like the screen is breathing with the keyboard.

If you've built forms in Flutter, you're probably already typing "just use resizeToAvoidBottomInset" into the comments. Hold that thought — that's failed attempt number one.


Everything that didn't work (and why — that's the good part)

I'm going to walk through the failures because each one taught me something real about how Flutter lays out a screen. If you just want the solution, skip ahead to "The reframe: the keyboard is an animation you can subscribe to." But the failures are where the understanding lives.

Attempt 1: resizeToAvoidBottomInset (let the Scaffold handle it)

Scaffold has resizeToAvoidBottomInset: true by default. When the keyboard opens, the Scaffold shrinks its body so nothing sits underneath the keyboard. Free fix, right?

Except our column was taller than the shrunken body. Hero + title + form + button doesn't fit in half a screen. The layout overflowed especially on smaller devices, and the button — the last child — was the thing that got pushed off the edge. The Scaffold resized; our content didn't care.

Lesson: resizeToAvoidBottomInset resizes the viewport. It doesn't make your content any shorter. Something still has to give, and by default the thing that gives is whatever's at the bottom. Which is exactly where your CTA lives.

Attempt 2: Collapse the image — Align(heightFactor) + AnimatedSize

Okay — so let's make the content shorter. Detect the keyboard, fold the hero away, and let AnimatedSize smooth the change:

final keyboardOpen = MediaQuery.viewInsetsOf(context).bottom > 0;

AnimatedSize(
  duration: const Duration(milliseconds: 200),
  curve: Curves.easeOut,
  child: ClipRect(
    child: Align(
      alignment: Alignment.topCenter,
      heightFactor: keyboardOpen ? 0.0 : 1.0,
      child: HeroImage(),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

And… it works! Tap the field, the keyboard comes up, the logo slides away, the button rises into view. Open a pull request, ship it, go home.

Then you use it for a minute, and something feels off. Watch the dismiss closely: the keyboard slides down on its own curve and duration — the OS's — while AnimatedSize runs its own tween with its own curve and duration. Two animations, two clocks, no coordination. The logo lands early or late, the keyboard is still moving after the image has settled (or the other way around), and the screen feels disconnected — like two dancers who can't hear the same song. It's not a connected flow; the logo and the keyboard each do their own thing.

Lesson: you cannot tween your way into sync with the keyboard. The only way to move with it is to be driven by it.

That lesson is the whole article, really. Let me show you what it means.


The reframe: the keyboard is an animation you can subscribe to

Here's the mental shift that unlocked everything.

When the OS animates the keyboard up, it reports — frame by frame — how many pixels of your window it currently covers. Flutter surfaces this as viewInsets.bottom. During the ~250ms slide, that value doesn't jump from 0 to 300. It sweeps: 0, 14, 43, 96, 170, 240, 287, 300.

The keyboard isn't an event. It's an animation, and the OS is broadcasting every frame of it.

(One honest caveat: the per-frame sweep is what you get on iOS and on Android 11+, where WindowInsetsAnimation drives the inset. On Android 10 and below the inset arrives as a single jump — the layout still ends up in exactly the right place, you just don't get the glide.)

So instead of listening for "keyboard opened" and starting our own animation (attempt 2's mistake), we bind our layout directly to that per-frame value. The keyboard becomes the single source of truth — the one clock everyone dances to. Perfect sync isn't something you tune; it falls out of the architecture.

Three pieces make it work.

Full, assembled source for all three pieces: [GIST/REPO LINK — one file, keyboard_aware_header.dart]. Code targets Flutter 3.10+ (it uses View.of); written against 3.27.


Piece 1: A widget that always knows the keyboard's height

We need the true inset, updated every frame. The trigger is WidgetsBindingObserver.didChangeMetrics, which fires on every window-metrics change — including each frame of the keyboard's slide — and the source is the raw FlutterView:

class KeyboardHeightBuilder extends StatefulWidget {
  const KeyboardHeightBuilder({required this.builder, super.key});

  final Widget Function(BuildContext context, double keyboardHeight) builder;

  @override
  State<KeyboardHeightBuilder> createState() => _KeyboardHeightBuilderState();
}

class _KeyboardHeightBuilderState extends State<KeyboardHeightBuilder>
    with WidgetsBindingObserver {
  final ValueNotifier<double> _keyboard = ValueNotifier<double>(0);

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _keyboard.dispose();
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    final view = View.of(context);
    _keyboard.value = view.viewInsets.bottom / view.devicePixelRatio;
  }

  @override
  Widget build(BuildContext context) => ValueListenableBuilder<double>(
        valueListenable: _keyboard,
        builder: (context, keyboard, _) => widget.builder(context, keyboard),
      );
}
Enter fullscreen mode Exit fullscreen mode

Three details worth pausing on:

The division. The raw FlutterView reports insets in physical pixels; your widget code speaks logical pixels. On a 3× display, a 900-physical-pixel keyboard is 300 logical pixels. Forget the / devicePixelRatio and everything you drive with this number will be two-to-three times too big. (The ambient MediaQuery does this conversion for you — the raw view does not.)

Why the raw view and not MediaQuery? Fair question. There's a trap worth knowing: when a Scaffold resizes for the keyboard (resizeToAvoidBottomInset: true, the default), it has already accounted for the inset — so it hands its body a MediaQuery with the bottom inset removed (you can see the removeViewInsets call in scaffold.dart). Ask "how big is the keyboard?" from inside that body and you're told "what keyboard?" Our final architecture turns the Scaffold's resizing off (Piece 3), so the ambient MediaQuery.viewInsetsOf would actually tell the truth here — but I read the raw view anyway, for two reasons: it makes this widget correct regardless of how any ancestor Scaffold is configured, and it lets me scope rebuilds myself instead of inheriting MediaQuery's. Treat it as a robustness-plus-control choice, not a hard requirement.

The ValueNotifier instead of setState. Calling setState here would re-run this State's build every frame. Routing the value through a ValueNotifier + ValueListenableBuilder is a micro-optimization on its own — the load-bearing optimization is in Piece 3, where the expensive subtree gets captured once and skipped entirely. Hold that thought.


Piece 2: Shrinking the image by exact pixels (the fun part)

Now the hard problem. I want to say: "render this image at its natural size, but make it occupy keyboard fewer pixels of height, clipping the excess off the top."

Go ahead and try to build that from stock widgets. I'll wait.

  • SizedBox(height: ...)? You'd need to know the image's rendered height up front — ours scales with screen width.
  • Align(heightFactor: ...)? Fractions only. There's no "shrink by 240 pixels."
  • Transform.translate? Moves the painting, not the layout. The image slides visually but keeps its full slot — nothing below it moves up. (Classic trap: transforms are paint-time, layout is done by then.)
  • CustomSingleChildLayout? So close. But its delegate decides the parent's size from the incoming constraints — it never gets to see the child's measured size. "Child's height minus N" is structurally impossible to express.

That last one stings, because "size me relative to my child" is exactly the job. When no widget can do a layout job, you drop one level down — to a render object. It sounds scarier than it is. Here's the whole thing:

class _ShrinkTop extends SingleChildRenderObjectWidget {
  const _ShrinkTop({required this.by, required super.child});

  final double by;

  @override
  _RenderShrinkTop createRenderObject(BuildContext context) =>
      _RenderShrinkTop(by);

  @override
  void updateRenderObject(BuildContext context, _RenderShrinkTop renderObject) {
    renderObject.shrinkBy = by;
  }
}

class _RenderShrinkTop extends RenderShiftedBox {
  _RenderShrinkTop(this._shrinkBy) : super(null);

  double _shrinkBy;
  set shrinkBy(double value) {
    if (_shrinkBy == value) return;
    _shrinkBy = value;
    markNeedsLayout();
  }

  @override
  void performLayout() {
    final child = this.child;
    if (child == null) {
      size = constraints.smallest;
      return;
    }
    child.layout(constraints.loosen(), parentUsesSize: true);
    final visible =
        (child.size.height - _shrinkBy).clamp(0.0, child.size.height);
    size = constraints.constrain(Size(child.size.width, visible));
    (child.parentData! as BoxParentData).offset =
        Offset(0, visible - child.size.height);
  }
}
Enter fullscreen mode Exit fullscreen mode

Twenty lines of actual logic. Three jobs inside performLayout — plus one accomplice outside the function:

  1. child.layout(..., parentUsesSize: true) — lay the image out at its natural size, and ask permission to read that size back.
  2. size = ... with child.size.height - _shrinkBy — and here's the magic: size is what we report to our parent. We're allowed to lie. The image is 430px tall, but we tell the enclosing Column "I'm 190px." The Column believes us and lays out the title 190px down. This one line is what pulls the entire rest of the screen upward. The clamp means a keyboard taller than the image just collapses us to zero rather than going negative.
  3. The offset — the child still paints at 430px, so we shift it up by the trimmed amount. The visible part is the image's bottom slice; the top slice hangs above our reported box.
  4. The accomplice: a ClipRect around the whole thing (in the wrapper widget below) — because layout and painting are separate passes. Reporting a small size doesn't stop the child from painting outside it; without the clip, the image happily draws over the title below. ClipRect cuts painting to our reported bounds.

Layout says how much room you take. Offset says which slice shows. Clip says nothing leaks. Three concerns, three mechanisms.

Why RenderShiftedBox as the base class? It's Flutter's "box with one child painted at an offset" primitive — it already handles child management, painting at parentData.offset, and hit-testing. We override exactly one method. (Its cousin RenderProxyBox won't do: it always adopts the child's size, and changing the size is our entire point.)

One trap to know about: this box reports its child's full intrinsic height — the shrink only happens in real layout. Park it under an IntrinsicHeight and the intrinsic measurement channel will grant it the full slot, silently pinning your collapse open. (Historical footnote: Align's heightFactor had a similar intrinsics blind spot on older Flutter SDKs; current SDKs factor it in. Custom render boxes like ours have the blind spot unless you override the computeIntrinsic* methods yourself.) Our final layout simply doesn't use IntrinsicHeight — the cleanest fix of all.

And the pretty public face:

class KeyboardAwareHeader extends StatelessWidget {
  const KeyboardAwareHeader({required this.child, super.key});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    return KeyboardHeightBuilder(
      builder: (context, keyboard) => ClipRect(
        child: _ShrinkTop(by: keyboard, child: child),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Note what's absent: no AnimatedSize, no duration, no curve. The keyboard's own per-frame value drives markNeedsLayout directly. When the OS says "I'm at 187px," the image is 187px shorter that frame. Open, dismiss, interrupted mid-slide — the image tracks perfectly, because it isn't running an animation. It's obeying one.

[IMAGE PLACEHOLDER 2 — short GIF: keyboard opening, dismissing, and being interrupted mid-slide, image tracking it exactly. A before/after against the Attempt-2 desynced version is even stronger.]


Piece 3: Parking the button on top of the keyboard

The image shrinking makes room, but something still has to position the button. I'd been burned enough by flexible-space tricks (a Spacer happily re-expands to eat whatever room you free) that I wanted something that couldn't be absorbed, couldn't be negotiated with. It turned out to be the most boring widget in Flutter — Padding (the snippet below is simplified from our production page; FlutterLogo and FilledButton are stand-ins for your hero image and CTA):

Scaffold(
  resizeToAvoidBottomInset: false,   // we're taking over
  body: SafeArea(
    child: KeyboardAwarePadding(     // Padding(bottom: keyboard) — see below
      padding: const EdgeInsets.symmetric(horizontal: 24),
      child: Column(
        children: [
          Expanded(
            child: SingleChildScrollView(
              child: Column(
                children: [
                  const KeyboardAwareHeader(
                    child: FlutterLogo(size: 300),   // your hero image
                  ),
                  // title, phone field, terms checkbox…
                ],
              ),
            ),
          ),
          FilledButton(                // your CTA — bottom of the padded area
            onPressed: () {},
            child: const Text('Get OTP'),
          ),
        ],
      ),
    ),
  ),
)
Enter fullscreen mode Exit fullscreen mode

The trick is Padding(bottom: keyboardHeight) around the entire body, with the Scaffold's own resizing turned off. The padding simply deletes the keyboard's strip from the layout. The body now ends where the keyboard begins — so the button cluster at the bottom of the column sits right on top of the keyboard. Nothing to cancel, nothing to absorb, no gap. When the keyboard closes, the padding is zero and the layout is byte-for-byte the original design.

(Why turn resizeToAvoidBottomInset off? Because if the Scaffold also resized, we'd be lifting the content twice — once by the resize, once by our padding — and the button would float a full keyboard-height above the keyboard.)

The Expanded(SingleChildScrollView(...)) handles the other axis of the problem: on a small phone with the keyboard open, hero + form may not fit even with the image fully collapsed. The scroll view absorbs the overflow. The button never scrolls — it's outside the scroll area, bolted above the keyboard.

And that "two screens" detail from the beginning? The OTP screen reuses KeyboardAwareHeader and KeyboardAwarePadding unchanged — which is the whole point of extracting them.

Wait — aren't Expanded and SingleChildScrollView contradictory?

This trips up almost everyone (it tripped me). Expanded says "take all the remaining space"; a scroll view exists for content bigger than its space. Opposites?

No — they operate at different levels of the constraint system. Constraints flow down; sizes flow up:

  1. The Column has a bounded height (screen minus padding).
  2. Expanded measures its siblings (the button), subtracts, and hands its child a tight, bounded height: "you are exactly 540px."
  3. The SingleChildScrollView takes those 540px as its viewport — and hands its child unbounded height: "be as tall as you like."
  4. The inner Column becomes its natural 700px. Taller than the viewport → it scrolls. Shorter → it just sits there.

Expanded bounds the scroll view; the scroll view unbounds its child. Complementary, not contradictory. In fact this pairing is the standard cure for the dreaded "Vertical viewport was given unbounded height" crash — which is precisely what you get if you drop a scroll view straight into a Column, because a Column offers its children infinite height and the viewport can't work with that.


The performance pass: rebuild a Padding, not a page

One more thing bugged me. KeyboardAwarePadding naïvely written would rebuild the whole body — column, scroll view, form, button — on every frame of the keyboard animation. Sixty rebuilds a second of widgets whose configuration hasn't changed.

The fix is a pattern you already know from AnimatedBuilder's child parameter, and it hinges on the fast path in Element.updateChild (from Flutter's framework.dart):

if (hasSameSuperclass && child.widget == newWidget) {
  // ...
  newChild = child;   // reuse the element — the subtree is skipped wholesale
}
Enter fullscreen mode Exit fullscreen mode

Widget.operator== is @nonVirtual and falls back to object identity — so if the child widget is the same instance as last frame, Flutter doesn't rebuild it, doesn't recurse into it, doesn't touch it. Create the expensive subtree once, outside the per-frame builder, and merely reference it inside:

class KeyboardAwarePadding extends StatelessWidget {
  const KeyboardAwarePadding({
    required this.child,
    this.padding = EdgeInsets.zero,
    super.key,
  });

  final EdgeInsets padding;
  final Widget child;          // ← created once by the page, captured here

  @override
  Widget build(BuildContext context) {
    return KeyboardHeightBuilder(
      builder: (context, keyboard) => Padding(
        padding: padding.copyWith(bottom: padding.bottom + keyboard),
        child: child,          // ← same instance every frame → subtree skipped
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The page builds its Column exactly once — pages don't rebuild during a keyboard animation as long as they don't depend on MediaQuery.of(context) (with the Scaffold's resizing off, the ambient MediaQueryData changes every frame of the slide; use aspect-scoped accessors like MediaQuery.sizeOf if you need screen dimensions, or you'll silently defeat this whole optimization). Every frame, the builder produces a fresh cheap Padding wrapping the identical Column instance, and the reconciler skips the subtree.

So the honest per-frame bill for the whole screen: two ValueNotifiers fire (padding + header), two featherweight builder subtrees rebuild (a Padding; a ClipRect/_ShrinkTop pair), and the image's size change relays out the column above it. No form rebuild, no button rebuild, no page rebuild. This is also the same reason const constructors matter — const widgets are canonicalized, so they're always identical across builds and always skipped. The child-capture pattern gets you that win when your subtree can't be const.


What I'd tattoo on my arm (a.k.a. takeaways)

  1. The keyboard is an animation, not an event — bind layout to viewInsets.bottom per frame; never run your own tween. (Per-frame on iOS and Android 11+.)
  2. MediaQuery.viewInsets can read zero inside a resizing Scaffold — the Scaffold consumed it. Read the raw FlutterView, and divide by devicePixelRatio.
  3. Intrinsic measurement is a separate channel — a custom render box that shrinks in layout still reports full intrinsic height, so keep it away from IntrinsicHeight.
  4. Transform moves paint, not layout — translated pixels don't free space.
  5. You can't out-spacer a Spacer — position bottom-anchored UI with padding.
  6. Expanded + SingleChildScrollView compose — one bounds the viewport, the other unbounds its child.
  7. A small render object is a tool, not overkillRenderShiftedBox + one performLayout override; size is yours to (honestly) lie about.
  8. Hoist expensive subtrees above per-frame builders — identical child instances are skipped wholesale by the reconciler.
  9. The iPhone phone pad has no Done key — for fixed-length input, the last digit is your Done button.

A login screen. The most basic UI in any app. It just took one custom render object, one raw-view listener, one constraint-system epiphany, and one small conspiracy against the Spacer widget to make it feel effortless.

That's the thing about "basic" UI — the polish that makes users feel nothing at all is where all the interesting engineering hides.


Top comments (0)