Module 15: Custom Widgets -- Building Your Own Lego Bricks

Production Polish

A developer assembling custom widget building blocks like Lego pieces

🎯

Teach: How to extract reusable widgets with constructor configuration and callback communication. See: Side-by-side comparisons of monolithic build methods vs. composed custom widgets. Feel: Empowered to build a personal widget toolkit that makes future development faster.

🎙️

Up until now, you have been using Flutter's built-in widgets -- Text, Column, Card, ListView, and dozens of others. That is like cooking with only pre-made ingredients from the grocery store. Tasty, sure. But the moment you want something specific -- a contact card with an online indicator, a button that shows a loading spinner, a card that expands when you tap it -- you need to build your own. Welcome to the kitchen. Time to learn the recipes.

Why Build Custom Widgets?

🎯

Teach: Why extracting reusable widgets beats copying and pasting widget code between screens. See: A before-and-after comparison of a monolithic build method vs. a clean one-liner custom widget call. Feel: Motivated to stop duplicating code and start building a personal widget library.

Think about every time you have copied and pasted a chunk of widget code from one screen to another, then tweaked a color or swapped an icon. That is the exact moment you should have been building a custom widget instead.

Three reasons custom widgets matter:

DRY (Don't Repeat Yourself). When the same card layout appears in three screens, a single CustomInfoCard widget means one place to fix bugs and one place to update the design.

Testability. A standalone widget with clear inputs and outputs can be widget-tested in isolation. You do not need to spin up the entire app just to verify that a delete button fires its callback.

Readability. Compare these two build methods:

// Before: a wall of nested widgets
Widget build(BuildContext context) {
  return Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        children: [
          Icon(Icons.info, color: Colors.blue, size: 32),
          const SizedBox(width: 16),
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text('Server Status', style: Theme.of(context).textTheme.titleMedium),
              Text('All systems operational'),
            ],
          ),
        ],
      ),
    ),
  );
}

// After: one readable line
Widget build(BuildContext context) {
  return CustomInfoCard(
    title: 'Server Status',
    description: 'All systems operational',
    icon: Icons.info,
  );
}

The second version tells you what it does at a glance. The first version makes you read 15 lines to figure out the same thing.

💡

If you are copying and pasting widget code between screens, stop. Extract it into a custom widget with constructor parameters instead.

🎙️

Here's a rule I want you to adopt. The second time you write the same widget code, refactor. Not the third time, not the fifth time. The second time. Because by the third time, you've already inherited three copies of the same bug when the design changes. Custom widgets are Flutter's main mechanism for code reuse, and they're astonishingly cheap — a class with a build method and a few fields. Get in the habit of watching for duplication as you code, and lift shared patterns up to their own file. Your future self will thank you every time a designer says "can we change how those cards look?"

Custom StatelessWidget Patterns

🎯

Teach: How to create a StatelessWidget configured entirely through its constructor. See: A complete InfoLabel widget with required and optional parameters. Feel: Comfortable with the pattern -- it is just a class with a build method.

A custom StatelessWidget is the simplest kind. Its appearance depends entirely on what you pass into the constructor. No internal state, no surprises.

class InfoLabel extends StatelessWidget {
  final String label;
  final String value;

  const InfoLabel({super.key, required this.label, required this.value});

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Text('$label: ', style: const TextStyle(fontWeight: FontWeight.bold)),
        Text(value),
      ],
    );
  }
}

Use it like this:

const InfoLabel(label: 'Name', value: 'Campbell')

That is the whole pattern. A class, some final fields, a const constructor, a build method. You will write hundreds of these.

🎙️

Think of a StatelessWidget like a pure function in math. Same inputs, same output, every single time. You hand it a label and a value, and it hands back the exact same Row of Text widgets. No memory, no mood swings, no hidden agenda. If your widget does not need to remember anything between builds, StatelessWidget is your answer.

Constructor Parameters for Configuration

The power of custom widgets comes from their constructors. Parameters make a widget flexible without making it complicated.

class ActionCard extends StatelessWidget {
  final String title;
  final String subtitle;
  final IconData icon;
  final Color color;
  final VoidCallback onTap;

  const ActionCard({
    super.key,
    required this.title,
    required this.subtitle,
    required this.icon,
    this.color = Colors.blue,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Card(
        color: color.withOpacity(0.1),
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Row(
            children: [
              Icon(icon, color: color, size: 32),
              const SizedBox(width: 16),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(title, style: Theme.of(context).textTheme.titleMedium),
                  Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Notice the mix of required and optional parameters. color has a default value of Colors.blue, so callers can omit it when blue is fine. Everything else is required because the card does not make sense without a title, subtitle, icon, and tap handler.

Use nullable types for truly optional features:

final VoidCallback? onTap;     // null means "not tappable"
final Widget? trailing;         // null means "nothing on the right side"

Then conditionally render based on whether the value is null:

if (onTap != null)
  InkWell(onTap: onTap, child: cardContent)
else
  cardContent

Callback Props -- Talking to Your Parents

🎯

Teach: How child widgets communicate upward to parent widgets using callback function parameters. See: A star-rating widget that fires a callback when the user taps a star, letting the parent decide what to do with the value. Feel: Clear on the data-down, actions-up pattern that keeps widgets independent and reusable.

🎙️

Here is a scenario. You build a beautiful star-rating widget. Five stars, tap to rate. But where does the selected rating go? The rating widget should not decide what happens with the rating. Maybe the parent screen saves it to a database. Maybe another parent screen just shows it in a snackbar. The widget does not care. It just says "hey, the user picked 4 stars" and lets the parent figure out the rest. That upward communication happens through callbacks.

Widgets communicate upward using callback functions passed as constructor parameters. The child widget calls the function. The parent widget provides the function.

class RatingBar extends StatelessWidget {
  final int rating;
  final ValueChanged<int> onRatingChanged;

  const RatingBar({
    super.key,
    required this.rating,
    required this.onRatingChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: List.generate(5, (index) {
        return IconButton(
          icon: Icon(
            index < rating ? Icons.star : Icons.star_border,
            color: Colors.amber,
          ),
          onPressed: () => onRatingChanged(index + 1),
        );
      }),
    );
  }
}

The parent uses it like this:

RatingBar(
  rating: _currentRating,
  onRatingChanged: (newRating) {
    setState(() => _currentRating = newRating);
  },
)

Common callback types you will use:

  • VoidCallback -- no arguments, no return value. For simple "something happened" signals.
  • ValueChanged<T> -- takes one argument of type T. For "here is the new value" signals.
  • Function(String, int) -- custom signatures when you need multiple arguments.

InkWell for Tap Feedback

When you want a tap effect with a Material ripple, wrap your widget in InkWell instead of GestureDetector:

InkWell(
  onTap: onTap,
  borderRadius: BorderRadius.circular(12),
  child: Padding(
    padding: const EdgeInsets.all(16),
    child: Text('Tap me for a ripple'),
  ),
)

InkWell gives you the standard Material Design ripple animation. GestureDetector gives you nothing visual -- it just detects the gesture silently. For most card and list item taps, InkWell is the better choice.

Custom StatefulWidget Patterns

🎯

Teach: When and how to use StatefulWidget for widgets that manage their own internal state. See: An expandable card that tracks its own expanded/collapsed state. Feel: Clear on the boundary between "state the parent owns" and "state the widget owns internally."

Use StatefulWidget when the widget needs to manage internal state that the parent does not care about. The classic example: an expandable card. The parent cares about the card's content. The parent does not care whether the card is currently expanded or collapsed -- that is an internal UI detail.

class ExpandableInfoCard extends StatefulWidget {
  final String title;
  final String description;
  final IconData icon;
  final Widget expandedContent;

  const ExpandableInfoCard({
    super.key,
    required this.title,
    required this.description,
    required this.icon,
    required this.expandedContent,
  });

  @override
  State<ExpandableInfoCard> createState() => _ExpandableInfoCardState();
}

class _ExpandableInfoCardState extends State<ExpandableInfoCard> {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onTap: () => setState(() => _isExpanded = !_isExpanded),
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
              Row(
                children: [
                  Icon(widget.icon, size: 32),
                  const SizedBox(width: 16),
                  Expanded(
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(widget.title,
                            style: Theme.of(context).textTheme.titleMedium),
                        Text(widget.description),
                      ],
                    ),
                  ),
                  Icon(_isExpanded
                      ? Icons.keyboard_arrow_up
                      : Icons.keyboard_arrow_down),
                ],
              ),
              AnimatedCrossFade(
                duration: const Duration(milliseconds: 300),
                firstChild: const SizedBox.shrink(),
                secondChild: Padding(
                  padding: const EdgeInsets.only(top: 16),
                  child: widget.expandedContent,
                ),
                crossFadeState: _isExpanded
                    ? CrossFadeState.showSecond
                    : CrossFadeState.showFirst,
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Notice how _isExpanded lives in the State class, not in the parent. The parent passes in title, description, icon, and expandedContent through the constructor. The widget handles the expand/collapse toggle on its own.

🎙️

Here is the rule of thumb. If the parent needs to know about it or control it, pass it as a constructor parameter with a callback. If it is purely a visual detail internal to the widget -- like whether a dropdown is open, whether a tooltip is showing, or whether a card is expanded -- let the widget manage it with setState. Not every piece of state needs to go up to the parent.

Widget Composition -- The Big Picture

🎯

Teach: How to assemble multiple custom widgets into a complete screen, each handling its own responsibility. See: A ProfileScreen built from InfoLabel, ActionCard, and RatingBar widgets composed together. Feel: That building a screen is like snapping together independent building blocks rather than writing one giant widget.

Build complex UIs by combining smaller custom widgets. Each widget handles its own slice of responsibility.

class ProfileScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const InfoLabel(label: 'Name', value: 'Alice'),
        const InfoLabel(label: 'Email', value: 'alice@example.com'),
        ActionCard(
          title: 'Edit Profile',
          subtitle: 'Update your information',
          icon: Icons.edit,
          onTap: () {},
        ),
        RatingBar(
          rating: 4,
          onRatingChanged: (r) => print('New rating: $r'),
        ),
      ],
    );
  }
}

Each widget is self-contained. InfoLabel does not know about ActionCard. RatingBar does not know it lives inside a ProfileScreen. They are independent building blocks that happen to be arranged together.

🎙️

This is the composition-over-inheritance principle made concrete. In an object-oriented world, you might think "I'll build an abstract ProfileWidget and extend it for each variant." In Flutter, you build small widgets and plug them together. The ProfileScreen doesn't inherit from anything special — it's just a column of other widgets. If tomorrow you want a TeamScreen that reuses InfoLabel and ActionCard but swaps RatingBar for a ContactList, you don't change InfoLabel or ActionCard at all. You assemble a different team of the same building blocks. That's what makes Flutter apps maintainable at scale.

🔄

Where this fits: Custom widgets are the bridge between knowing Flutter's built-in widgets (Modules 5-10) and building real applications (Modules 18-19). Every production Flutter app is made of custom widgets composed together.

There Are No Dumb Questions

🎯

Teach: Answers to common questions about file organization, const constructors, callback types, and mixing state with callbacks. See: Practical guidance on decisions developers face every time they create a custom widget. Feel: Reassured that these questions are normal and the answers are straightforward.

Q: When should I extract a widget into its own file vs. keeping it in the same file?

A: If you use the widget in more than one screen, give it its own file in a widgets/ directory. If it is only used inside one screen and is relatively small, keeping it in the same file is fine. When in doubt, separate it. You will never regret having too many small, focused files.

Q: Should I always use const constructors?

A: Yes, whenever possible. If all your constructor parameters are final and you are not doing any computation in the constructor body, add const. Flutter can optimize const widgets by reusing them instead of rebuilding. Your linter will usually remind you.

Q: What is the difference between VoidCallback and Function()?

A: They are functionally identical. VoidCallback is a typedef defined by Flutter that equals void Function(). Using VoidCallback is more idiomatic and readable in Flutter code.

Q: Can a custom widget have both internal state AND callback props?

A: Absolutely. A SearchBar widget might manage its own TextEditingController internally (StatefulWidget) while exposing an onSearch callback to the parent. Internal state for the UI mechanics, callbacks for the meaningful events.

🎙️

The const question is one of those small-sounding things that has a surprisingly big impact. Adding const to a widget constructor tells Flutter "these parameters cannot change, so you can cache this widget instance and reuse it forever." For widgets rebuilt inside long lists, that optimization can be the difference between smooth scrolling and janky scrolling. Modern Flutter linter rules will nag you about missing const keywords — listen to them. They're almost always right, and adopting the habit early means you'll never have to go back and add them in bulk.

📝 Module Quiz

Test what you learned. 45 seconds per question. Passing score is set per module. Your best attempt is saved in your browser so you can track progress -- nothing is sent to a server.

Sharpen Your Pencil

🎯

Teach: How to apply custom widget patterns by building progressively complex widgets from scratch. See: Five exercises that move from a simple StatelessWidget to a full composed screen of custom widgets. Feel: Ready to practice extracting, configuring, and composing widgets on your own.

Exercise 1: CustomInfoCard

Create a CustomInfoCard StatelessWidget with these parameters:

  • title (String, required) -- bold header text
  • description (String, required) -- body text below the title
  • icon (IconData, required) -- displayed on the left
  • iconColor (Color, optional, default Colors.blue)
  • onTap (VoidCallback?, optional) -- if provided, wrap the card in an InkWell
  • trailing (Widget?, optional) -- displayed on the right side

Layout it as a Card with a ListTile-style arrangement: icon left, title and description stacked in the center, optional trailing widget on the right.

Exercise 2: ExpandableInfoCard

Create an ExpandableInfoCard StatefulWidget that extends the card concept:

  • Same parameters as CustomInfoCard plus expandedContent (Widget, required)
  • Internal _isExpanded boolean state starting at false
  • Tapping toggles expansion with an AnimatedCrossFade or AnimatedSize transition
  • Show a chevron icon (down when collapsed, up when expanded) as the trailing widget

Exercise 3: CustomButton

Create a CustomButton StatelessWidget with a ButtonType enum:

enum ButtonType { primary, secondary, danger }

Parameters: label, onPressed, optional icon, required style (ButtonType), optional isLoading (shows a CircularProgressIndicator and disables the button), optional isFullWidth.

Color mapping: primary = blue/white, secondary = grey/black, danger = red/white.

Exercise 4: ContactListItem

Create a ContactListItem StatelessWidget for displaying a contact:

  • name and email (required), avatarUrl (optional String), isOnline (bool, default false), onTap and onDelete (both optional)
  • Use a CircleAvatar with NetworkImage if avatarUrl is provided, otherwise show the first letter of the name
  • Use a Stack to overlay a small green dot on the avatar when isOnline is true
  • Conditionally show a delete icon button when onDelete is provided

Exercise 5: Composing a Screen

Build a ContactsScreen that composes all the widgets above into a working screen with a hardcoded list of 5 contacts, add/delete functionality, and at least one ExpandableInfoCard.

🎙️

I want you to build these in order. Exercise 1 is pure StatelessWidget. Exercise 2 adds internal state. Exercise 3 introduces an enum-driven variant system. Exercise 4 combines optional callbacks with conditional rendering. And Exercise 5 pulls them all together. Each exercise teaches one new technique, and by the time you're composing the screen, you're just arranging widgets you already understand. That layered learning is how you build real expertise. Skipping ahead to the hard exercise without mastering the foundation means the hard exercise will take twice as long.

💡

The best Flutter developers do not write more code. They write better widgets. Every reusable widget you build today saves you hours tomorrow.

1 / 1