Everything in Flutter is a widget. Buttons, text, padding, the layout itself. Widgets are Dart classes that describe what the UI should look like. When data changes, Flutter rebuilds the relevant widgets and updates the screen.

This guide assumes you know Dart basics (classes, functions, types). If not, start with Programming Basics. For the full widget catalog, see the official Flutter widget docs.

Stateless Widgets

A stateless widget never changes after it is built. It takes data in, renders UI, and that is it. Use it for anything that does not need to update on its own, like a profile card or a label.

dart
class UserCard extends StatelessWidget {
  final String name;
  final int age;

  const UserCard({super.key, required this.name, required this.age});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text('$name, age $age'),
    );
  }
}

The build method returns the widget tree. Flutter calls it whenever this widget needs to be drawn. You do not call it yourself. Mark properties final and the constructor const whenever possible so Flutter can optimize rebuilds.

Stateful Widgets

A stateful widget can change over time. A counter that increments, a checkbox that toggles, a text field the user types into. The widget class stays immutable, but it creates a separate State object that holds the data that can change.

dart
class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Text('Count: $count', style: TextStyle(fontSize: 24)),
        SizedBox(height: 16),
        ElevatedButton(
          onPressed: () {
            setState(() {
              count++;
            });
          },
          child: Text('Add'),
        ),
      ],
    );
  }
}

Call setState to tell Flutter the data changed. Flutter re-runs build and updates the screen. Never mutate state outside of setState or the UI will not update.

Lifecycle

Stateful widgets go through a lifecycle. Three methods you will use constantly: initState runs once when the widget is first created (set up controllers, fetch data), build runs every time the state changes (return your UI), and dispose runs when the widget is permanently removed (clean up resources).

dart
class ProfileScreen extends StatefulWidget {
  const ProfileScreen({super.key});

  @override
  State<ProfileScreen> createState() => _ProfileScreenState();
}

class _ProfileScreenState extends State<ProfileScreen> {

  @override
  void initState() {
    super.initState();
    // Runs once when the widget is first created.
    // Fetch data, start timers, set up controllers.
  }

  @override
  Widget build(BuildContext context) {
    // Runs every time setState is called or the parent rebuilds.
    // Return the widget tree here.
    return Text('Profile');
  }

  @override
  void dispose() {
    // Runs when the widget is removed from the tree.
    // Cancel timers, close streams, dispose controllers.
    super.dispose();
  }
}

Always call super.initState() first and super.dispose() last. If you create a controller in initState, dispose it in dispose. Forgetting this causes memory leaks.

Scaffold

Most screens start with a Scaffold. It provides the standard Material Design page layout: an app bar at the top, a body in the center, and optional elements like a floating action button or bottom navigation bar.

dart
Scaffold(
  appBar: AppBar(
    title: Text('Home'),
  ),
  body: Center(
    child: Text('Hello World'),
  ),
  floatingActionButton: FloatingActionButton(
    onPressed: () {},
    child: Icon(Icons.add),
  ),
)

Common Widgets

These are the building blocks you will use on almost every screen.

Text

Displays a string. You can style it with TextStyle and control overflow behavior for long text that might not fit.

dart
Text(
  'Hello World',
  style: TextStyle(
    fontSize: 18,
    fontWeight: FontWeight.bold,
    color: Colors.black,
    letterSpacing: 0.5,
  ),
  maxLines: 2,
  overflow: TextOverflow.ellipsis,
)

Container

A versatile box that can have padding, margin, a background color, rounded corners, shadows, and a fixed size. Think of it as a div with inline styles. If you only need spacing, use SizedBox. If you only need padding, use Padding.

dart
Container(
  width: 200,
  height: 100,
  padding: EdgeInsets.all(16),
  margin: EdgeInsets.symmetric(vertical: 8),
  decoration: BoxDecoration(
    color: Colors.blue,
    borderRadius: BorderRadius.circular(12),
    boxShadow: [
      BoxShadow(
        color: Colors.black26,
        blurRadius: 8,
        offset: Offset(0, 2),
      ),
    ],
  ),
  child: Text('Styled box'),
)

SizedBox

The simplest way to add fixed spacing between widgets or give something a fixed size. Use it instead of Container when you just need a gap.

dart
Column(
  children: [
    Text('Above'),
    SizedBox(height: 16),  // 16px vertical gap
    Text('Below'),
  ],
)

SizedBox(
  width: 100,
  height: 50,
  child: ElevatedButton(onPressed: () {}, child: Text('Fixed')),
)

Image & Icon

Display images from your project assets or from the web. For icons, Flutter includes the full Material icon set built in.

dart
// Asset image (add file to assets/ and list in pubspec.yaml)
Image.asset('assets/photo.png', width: 120),

// Network image (loads from URL)
Image.network('https://example.com/photo.png'),

// Material icon
Icon(Icons.favorite, color: Colors.red, size: 24),

Buttons

Flutter gives you three standard button styles. ElevatedButton is a filled button for primary actions. TextButton is flat text for secondary actions. IconButton is just an icon, often used in app bars. For custom tap areas, GestureDetector makes any widget tappable.

dart
// Filled button — primary actions
ElevatedButton(
  onPressed: () {},
  child: Text('Submit'),
)

// Text-only button — secondary actions
TextButton(
  onPressed: () {},
  child: Text('Cancel'),
)

// Icon button — toolbars, app bars
IconButton(
  onPressed: () {},
  icon: Icon(Icons.delete),
)

// Make anything tappable
GestureDetector(
  onTap: () {},
  child: Container(
    padding: EdgeInsets.all(12),
    child: Text('Tap me'),
  ),
)

Set onPressed to null to disable any button. GestureDetector also supports onLongPress, onDoubleTap, and swipe gestures.

Row

Lays children out horizontally, left to right. Use it when you need items side by side, like an icon next to a label.

dart
Row(
  mainAxisAlignment: MainAxisAlignment.spaceBetween,
  crossAxisAlignment: CrossAxisAlignment.center,
  children: [
    Icon(Icons.star),
    Text('Title'),
    Icon(Icons.arrow_forward),
  ],
)

Column

Lays children out vertically, top to bottom. Same alignment options as Row, just on different axes. Use it for stacking content like headings, body text, and buttons.

dart
Column(
  mainAxisAlignment: MainAxisAlignment.center,
  crossAxisAlignment: CrossAxisAlignment.start,
  children: [
    Text('First'),
    SizedBox(height: 8),
    Text('Second'),
    SizedBox(height: 8),
    Text('Third'),
  ],
)

Alignment

Every Row and Column has two axes. The main axis is the direction children flow (horizontal for Row, vertical for Column). The cross axis is perpendicular to that. mainAxisAlignment controls spacing along the flow. crossAxisAlignment controls alignment across it.

dart
// Main axis (direction children flow)
MainAxisAlignment.start        // pack at the start
MainAxisAlignment.center       // center all
MainAxisAlignment.end          // pack at the end
MainAxisAlignment.spaceBetween // equal space between items
MainAxisAlignment.spaceEvenly  // equal space around each
MainAxisAlignment.spaceAround  // half space at edges

// Cross axis (perpendicular)
CrossAxisAlignment.start
CrossAxisAlignment.center
CrossAxisAlignment.end
CrossAxisAlignment.stretch     // fill available space

Expanded & Spacer

Expanded makes a child fill all remaining space in the Row or Column. Use flex to control the ratio when you have multiple. Spacer is a shortcut that pushes siblings apart.

dart
// Two children splitting space 2:1
Row(
  children: [
    Expanded(
      flex: 2,
      child: Container(color: Colors.red, height: 50),
    ),
    Expanded(
      flex: 1,
      child: Container(color: Colors.blue, height: 50),
    ),
  ],
)

// Push items to opposite ends
Row(
  children: [
    Text('Left'),
    Spacer(),
    Text('Right'),
  ],
)

Stack

Layers children on top of each other, like stacking cards. The first child is at the bottom, the last is on top. Use Positioned to pin a child to exact coordinates within the stack. Useful for badges, overlays, and floating labels.

dart
Stack(
  children: [
    // Bottom layer — fills the stack
    Container(
      width: 300,
      height: 200,
      decoration: BoxDecoration(
        color: Colors.grey[200],
        borderRadius: BorderRadius.circular(16),
      ),
    ),

    // Top-right badge
    Positioned(
      top: 8,
      right: 8,
      child: Container(
        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
        decoration: BoxDecoration(
          color: Colors.red,
          borderRadius: BorderRadius.circular(12),
        ),
        child: Text('NEW', style: TextStyle(color: Colors.white, fontSize: 12)),
      ),
    ),

    // Centered label
    Center(
      child: Text('Card Title', style: TextStyle(fontSize: 18)),
    ),
  ],
)

ListView

A scrollable list. For a small number of children, pass them directly. For long or dynamic lists, use ListView.builder instead. It only builds the items currently visible on screen, so it stays fast even with thousands of items.

Basic

When you have a short, fixed list of children, just pass them in directly.

dart
ListView(
  padding: EdgeInsets.all(16),
  children: [
    ListTile(title: Text('Item 1')),
    ListTile(title: Text('Item 2')),
    ListTile(title: Text('Item 3')),
  ],
)

Builder

When your list comes from data (an API, a database, a list variable), use ListView.builder. It lazily constructs items as the user scrolls, keeping memory usage low.

dart
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      leading: CircleAvatar(child: Text('${index + 1}')),
      title: Text(items[index].name),
      subtitle: Text(items[index].email),
      trailing: Icon(Icons.chevron_right),
      onTap: () {},
    );
  },
)

Separated

Same as builder, but inserts a separator widget between each item. Commonly used with Divider() for a clean list.

dart
ListView.separated(
  itemCount: items.length,
  separatorBuilder: (context, index) => Divider(height: 1),
  itemBuilder: (context, index) {
    return ListTile(title: Text(items[index]));
  },
)

GridView

Displays items in a scrollable grid. crossAxisCount sets how many columns. Use GridView.builder for dynamic data just like ListView.builder.

dart
GridView.builder(
  padding: EdgeInsets.all(16),
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 2,
    crossAxisSpacing: 12,
    mainAxisSpacing: 12,
  ),
  itemCount: items.length,
  itemBuilder: (context, index) {
    return Card(
      child: Center(child: Text(items[index])),
    );
  },
)

If you put a ListView inside a Column, wrap it in Expanded or give it a fixed height with SizedBox. Without a bounded height, Flutter cannot figure out how tall the list should be and will throw an error.

Forms

TextField

The basic text input widget. Attach a TextEditingController to read the current value and control the text programmatically. Always dispose the controller in dispose to avoid memory leaks.

dart
class SearchBar extends StatefulWidget {
  const SearchBar({super.key});

  @override
  State<SearchBar> createState() => _SearchBarState();
}

class _SearchBarState extends State<SearchBar> {
  final controller = TextEditingController();

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      decoration: InputDecoration(
        hintText: 'Search...',
        prefixIcon: Icon(Icons.search),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      onChanged: (value) {
        // Called every keystroke
      },
      onSubmitted: (value) {
        // Called when the user presses done/enter
      },
    );
  }
}

Form Validation

When you have multiple fields that need validation (like a sign-up form), wrap them in a Form widget with a GlobalKey. Use TextFormField instead of TextField inside forms. Each field gets a validator function that returns null when valid or an error message string when invalid.

dart
class SignUpForm extends StatefulWidget {
  const SignUpForm({super.key});

  @override
  State<SignUpForm> createState() => _SignUpFormState();
}

class _SignUpFormState extends State<SignUpForm> {
  final formKey = GlobalKey<FormState>();
  final emailController = TextEditingController();
  final passwordController = TextEditingController();

  @override
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  void submit() {
    if (formKey.currentState!.validate()) {
      // All fields passed — do something with the data
      final email = emailController.text;
      final password = passwordController.text;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: formKey,
      child: Column(
        children: [
          TextFormField(
            controller: emailController,
            decoration: InputDecoration(labelText: 'Email'),
            keyboardType: TextInputType.emailAddress,
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Email is required';
              }
              if (!value.contains('@')) {
                return 'Enter a valid email';
              }
              return null;
            },
          ),
          SizedBox(height: 12),
          TextFormField(
            controller: passwordController,
            decoration: InputDecoration(labelText: 'Password'),
            obscureText: true,
            validator: (value) {
              if (value == null || value.length < 6) {
                return 'Must be at least 6 characters';
              }
              return null;
            },
          ),
          SizedBox(height: 24),
          SizedBox(
            width: double.infinity,
            child: ElevatedButton(
              onPressed: submit,
              child: Text('Sign Up'),
            ),
          ),
        ],
      ),
    );
  }
}

Calling validate()runs every field's validator at once. The form shows errors below each invalid field automatically.