github logo
Get Source Code

Make a Text Field Look Like Real Paper In Flutter

If you like the guide, follow me on Twitter to get updates on my latest tutorials.

Take a look at what we will be building below!

Paper Text Field

The Concept

It works by having a transparent TextField with defined fontSize and height attributes. The line height is calculated by multiplying the fontSize by the height. The "page lines" are drawn behind the TextField. How do we know how many lines to draw? Well, by design, the height of the TextField will always be a multiple of the line height so all we do is divide the height of the TextField by the line height to get the number of lines.

The Code

The folder structure looks like this

Inside your paper_field.dart file, add the following code and read the explanation below it.

import 'dart:math';

import 'package:flutter/material.dart';

double _kFontSize = 32.0;
double _kHeight = 2.0;
double _kLineHeight = _kFontSize * _kHeight;
double _kInitialHeight = _kLineHeight * 5;

class PaperField extends StatefulWidget {
  final String initialText;


  State<StatefulWidget> createState() {
    return _PaperFieldState();

class _PaperFieldState extends State<PaperField> {
  GlobalKey _textFieldKey;
  TextEditingController _controller;
  double lastKnownHeight = _kInitialHeight;

  initState() {
    _textFieldKey = GlobalKey();
    _controller = new TextEditingController();
    _controller.text = widget.initialText;

    // Wait for all widgets to be drawn before trying to draw lines again
    WidgetsBinding.instance.addPostFrameCallback((_) => _setLastKnownHeight());

  void _setLastKnownHeight() {
    final RenderBox renderBoxTextField = _textFieldKey.currentContext.findRenderObject();
    final size = renderBoxTextField.size;

    if (lastKnownHeight != size.height) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        // Causes the widget to rebuild
        // (so the lines get redrawn)
        setState(() {
          lastKnownHeight = size.height;

  Widget _buildLines() {
    // Calculate the number of lines that need to be built
    int nLines = max(_kInitialHeight, lastKnownHeight) ~/ _kLineHeight;

    // Draw the lines (which are just containers with a bottom border)
    return Column(
      children: List.generate(
        (index) => Container(
          decoration: BoxDecoration(
            border: Border(
              bottom: BorderSide(
                color: Colors.grey[400]
          height: _kLineHeight,

  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.symmetric(horizontal: 10.0),
      child: Column(
        children: [
            children: [
              // Add before TextField so it appears under it
                constraints: BoxConstraints(
                  minHeight: _kInitialHeight,
                child: Container(
                  child: NotificationListener<SizeChangedLayoutNotification>(
                    onNotification: (SizeChangedLayoutNotification notification) {
                      // Set the new TextField height whenever it changes
                      return true;
                    child: SizeChangedLayoutNotifier( // Listen for when the TextField's height changes
                      child: TextField(
                        key: _textFieldKey,
                        controller: _controller,
                        cursorHeight: _kLineHeight * 0.6,
                        cursorWidth: 4,
                        maxLines: null,
                        decoration: _inputDecoration(),
                        keyboardType: TextInputType.multiline,
                        style: TextStyle(
                          fontSize: _kFontSize,
                          height: _kHeight,
                          color: Colors.grey[600]

  // Flatten out and "plainify" the TextField so it doesn't have any
  // unwanted dimensions. VERY IMPORTANT!
  InputDecoration _inputDecoration() {
    return InputDecoration(
      isDense: true,
      border: InputBorder.none,
      enabledBorder: InputBorder.none,
      focusedBorder: InputBorder.none,
      errorBorder: InputBorder.none

It definitely is a lot. Let me explain.

There is a TextEditingController for our TextField. When our widget first loads, initState is called, and we set the initial text of the TextField to the value our widget took in (if any).

Once every widget has been built for the first time, we try to adjust the number of lines that should be drawn in case the initial text passed in overflows the initial height of the TextField.

Inside the build function we have two key widgets that we need to understand: The Widget from the buildLines method and the actual TextField

What happens is, we wrap the TextField with a SizeChangedLayoutNotifier and a NotificationListener. We do this so we can know when to rebuild our widget and the buildLines method can be called. Notice that we don't call setState immediately when the notification is dispatched. We use a post frame callback instead to avoid calling setState during build as that could be quite catastrophic. The TextField also has a key attached to it, so we can determine its height at runtime.

In the buildLines method, we calculate the number of lines we need by dividing the height of the text field by the line height.

There are some additional styles and tweeks, but they are quite self-explanatory so I won't explain them.


To use, just call it like so:

  initialText: "Hey, I am a paper field"

Want more tips? Follow me on Twitter to get updates on my latest tutorials.