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.
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.
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).
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.
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.
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.
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.
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.
// 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.
// 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.
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.
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.
// 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 spaceExpanded & 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.
// 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.
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.
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.
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.
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.
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.
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.
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.