Intermediate
Group 11: Inherited State
Example 28: InheritedWidget
InheritedWidget is Flutter’s built-in mechanism for propagating data down the widget tree without explicit parameter passing. It is the foundation on which Provider, Theme, and MediaQuery are all built. Understanding InheritedWidget demystifies how these higher-level solutions work.
graph TD
A["InheritedWidget<br/>AppState data at top"] --> B["Child Widget A<br/>context.dependOnInheritedWidget"]
A --> C["Child Widget B<br/>context.dependOnInheritedWidget"]
B --> D["GrandChild<br/>inherits state"]
style A fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
style C fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
style D fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
import 'package:flutter/material.dart';
// Define the InheritedWidget that holds shared state
class AppThemeData extends InheritedWidget {
final bool isDarkMode; // => The data to share down the tree
final VoidCallback toggleTheme; // => Callback to mutate shared state
const AppThemeData({
super.key,
required this.isDarkMode, // => Required data field
required this.toggleTheme,
required super.child, // => The subtree this widget wraps
});
// Static of() method is the conventional access pattern
// dependOnInheritedWidgetOfExactType registers the caller for rebuilds
static AppThemeData of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<AppThemeData>()!;
// => Registers this context to rebuild when AppThemeData.updateShouldNotify returns true
}
// updateShouldNotify tells Flutter whether dependents should rebuild
@override
bool updateShouldNotify(AppThemeData oldWidget) {
return isDarkMode != oldWidget.isDarkMode; // => Only rebuild if isDarkMode changed
// => If false, dependents do NOT rebuild even though the widget was rebuilt
}
}
class InheritedWidgetDemo extends StatefulWidget {
const InheritedWidgetDemo({super.key});
@override
State<InheritedWidgetDemo> createState() => _InheritedWidgetDemoState();
}
class _InheritedWidgetDemoState extends State<InheritedWidgetDemo> {
bool _isDarkMode = false; // => State lives in the ancestor
@override
Widget build(BuildContext context) {
return AppThemeData(
isDarkMode: _isDarkMode, // => Pass current state into tree
toggleTheme: () => setState(() => _isDarkMode = !_isDarkMode),
// => Wrap entire subtree so any descendant can access AppThemeData
child: MaterialApp(
theme: _isDarkMode ? ThemeData.dark() : ThemeData.light(),
home: const ThemeConsumerPage(),
),
);
}
}
class ThemeConsumerPage extends StatelessWidget {
const ThemeConsumerPage({super.key});
@override
Widget build(BuildContext context) {
// Access shared state via the static of() method - no prop drilling
final themeData = AppThemeData.of(context); // => Reads from InheritedWidget
// => Registers this widget for rebuilds
return Scaffold(
appBar: AppBar(title: const Text('InheritedWidget')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Dark mode: ${themeData.isDarkMode}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: themeData.toggleTheme, // => Calls ancestor's setState
child: const Text('Toggle Theme'),
),
],
),
),
);
}
}
void main() => runApp(const InheritedWidgetDemo());
// => Theme toggle updates all consumers without prop drillingKey Takeaway: InheritedWidget propagates data down the tree; dependOnInheritedWidgetOfExactType in of() registers the consumer for rebuilds when updateShouldNotify returns true.
Why It Matters: Every Flutter developer should understand InheritedWidget because it is the engine behind Theme.of(context), MediaQuery.of(context), and all state management libraries. When Provider rebuilds only affected widgets, it uses updateShouldNotify under the hood. Understanding this mechanism helps debug unexpected rebuilds and performance issues in production applications.
Example 29: Provider Package
Provider is the community-recommended wrapper around InheritedWidget that eliminates boilerplate. ChangeNotifier + ChangeNotifierProvider is the most common pattern: the model holds state and calls notifyListeners() to trigger rebuilds in Consumer or context.watch callers.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; // => Add provider: ^6.1.0 to pubspec.yaml
// Model extends ChangeNotifier to provide reactive state
class CartModel extends ChangeNotifier {
final List<String> _items = []; // => Private mutable items list
List<String> get items => List.unmodifiable(_items); // => Expose immutable view
int get count => _items.length; // => Computed property
void addItem(String item) {
_items.add(item); // => Mutate private state
notifyListeners(); // => Trigger rebuilds in all consumers
}
void removeItem(String item) {
_items.remove(item);
notifyListeners(); // => Notify after every mutation
}
void clear() {
_items.clear();
notifyListeners();
}
}
void main() {
runApp(
// ChangeNotifierProvider creates and disposes the model automatically
ChangeNotifierProvider(
create: (context) => CartModel(), // => Factory creates the model instance
child: const MaterialApp(home: ShopPage()),
),
);
}
class ShopPage extends StatelessWidget {
const ShopPage({super.key});
static const _products = ['Apple', 'Banana', 'Cherry', 'Dragonfruit'];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Shop'),
actions: [
// context.watch<T>() - rebuilds this widget when CartModel notifies
Consumer<CartModel>(
builder: (context, cart, _) => Badge.count(
count: cart.count, // => Cart item count in badge
child: const Icon(Icons.shopping_cart),
),
),
],
),
body: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
title: Text(product),
trailing: IconButton(
icon: const Icon(Icons.add_shopping_cart),
onPressed: () {
// context.read<T>() - access model without registering for rebuilds
context.read<CartModel>().addItem(product);
// => context.read used in callbacks (no rebuild needed from here)
},
),
);
},
),
floatingActionButton: Consumer<CartModel>(
builder: (context, cart, _) => FloatingActionButton.extended(
onPressed: cart.count > 0 ? cart.clear : null, // => Disable if empty
label: Text('Clear Cart (${cart.count})'),
icon: const Icon(Icons.delete),
),
),
);
}
}
// => Cart count updates everywhere when items are added or removedKey Takeaway: Extend ChangeNotifier and call notifyListeners() after mutations; use context.watch<T>() in build methods to rebuild on changes; use context.read<T>() in callbacks where you only need to call methods.
Why It Matters: Provider is the most widely used Flutter state management library and is recommended by the Flutter team for most use cases. The context.read vs context.watch distinction is critical - calling context.watch in a button callback causes unnecessary rebuilds, while calling context.read in a build method means changes never trigger a rebuild. This distinction prevents both bugs and performance issues in production apps.
Example 30: Riverpod
Riverpod improves on Provider with compile-time safety, no BuildContext requirement for reads, and better separation of concerns. StateNotifierProvider is the Riverpod equivalent of Provider’s ChangeNotifierProvider. Providers are declared globally as final variables.
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; // => flutter_riverpod: ^2.5.0
// Define a state class (immutable data)
class TodoState {
final List<String> todos;
final bool isLoading;
const TodoState({required this.todos, this.isLoading = false});
// copyWith creates new instance with updated fields (immutable update pattern)
TodoState copyWith({List<String>? todos, bool? isLoading}) => TodoState(
todos: todos ?? this.todos, // => Use provided value or keep existing
isLoading: isLoading ?? this.isLoading,
);
}
// StateNotifier holds state and exposes mutation methods
class TodoNotifier extends StateNotifier<TodoState> {
TodoNotifier() : super(const TodoState(todos: [])); // => Initial state via super()
void add(String todo) {
state = state.copyWith(todos: [...state.todos, todo]); // => Immutable update
// => Assigning to state triggers all provider listeners to rebuild
}
void remove(String todo) {
state = state.copyWith(
todos: state.todos.where((t) => t != todo).toList(),
);
}
Future<void> loadFromServer() async {
state = state.copyWith(isLoading: true); // => Show loading indicator
await Future.delayed(const Duration(seconds: 1)); // => Simulate network call
state = state.copyWith(
todos: ['Buy groceries', 'Write Flutter code', 'Exercise'],
isLoading: false, // => Hide loading indicator
);
}
}
// Global provider declaration - accessible anywhere without context
final todoProvider = StateNotifierProvider<TodoNotifier, TodoState>(
(ref) => TodoNotifier(), // => Factory creates the notifier
);
void main() {
runApp(
const ProviderScope( // => ProviderScope is required at app root
child: MaterialApp(home: TodoPage()),
),
);
}
// ConsumerWidget is Riverpod's equivalent of StatelessWidget + Consumer
class TodoPage extends ConsumerWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// ref.watch rebuilds this widget when todoProvider state changes
final state = ref.watch(todoProvider); // => Subscribes to state changes
return Scaffold(
appBar: AppBar(title: const Text('Riverpod Todos')),
body: state.isLoading
? const Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
final todo = state.todos[index];
return ListTile(
title: Text(todo),
trailing: IconButton(
icon: const Icon(Icons.delete),
// ref.read in callbacks - no rebuild subscription needed
onPressed: () => ref.read(todoProvider.notifier).remove(todo),
// => .notifier gives access to TodoNotifier methods
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => ref.read(todoProvider.notifier).loadFromServer(),
child: const Icon(Icons.download),
),
);
}
}
// => Riverpod provider manages async loading and todo list state globallyKey Takeaway: Declare Riverpod providers as global final variables; use ref.watch in build to subscribe to changes; use ref.read in callbacks; StateNotifier holds immutable state updated via state = state.copyWith(...).
Why It Matters: Riverpod solves the key weaknesses of Provider - no more ProviderNotFoundException at runtime, providers can depend on each other without nesting, and testing requires no BuildContext manipulation. Many production teams prefer Riverpod for its compile-time safety and testability. The immutable state pattern with copyWith also makes state transitions explicit and debuggable.
Group 12: Networking
Example 31: HTTP GET Request
The http package provides straightforward HTTP operations. Always handle loading, success, and error states explicitly. Parse JSON with json.decode from dart:convert. Use typed model classes instead of raw Map<String, dynamic> for maintainability.
import 'dart:convert'; // => dart:convert for json.decode
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; // => http: ^1.2.0 in pubspec.yaml
// Typed model class for API response
class Post {
final int id;
final String title;
final String body;
const Post({required this.id, required this.title, required this.body});
// Factory constructor parses JSON map into typed object
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as int, // => Cast to expected type
title: json['title'] as String, // => Throws if wrong type
body: json['body'] as String,
);
}
}
// Service function that performs the HTTP request
Future<List<Post>> fetchPosts() async {
final url = Uri.parse('https://jsonplaceholder.typicode.com/posts'); // => Parse URL string
final response = await http.get(url); // => Perform GET request
// => response.statusCode is the HTTP status (200, 404, 500, etc.)
// => response.body is the response body as a String
if (response.statusCode != 200) {
throw Exception('Failed to load posts: ${response.statusCode}');
// => Throw exception for non-200 responses so callers can handle errors
}
final List<dynamic> data = json.decode(response.body); // => Parse JSON string to List
return data.map((json) => Post.fromJson(json as Map<String, dynamic>)).toList();
// => Transform each JSON map into a typed Post object
}
void main() => runApp(const MaterialApp(home: PostsPage()));
class PostsPage extends StatefulWidget {
const PostsPage({super.key});
@override
State<PostsPage> createState() => _PostsPageState();
}
class _PostsPageState extends State<PostsPage> {
late Future<List<Post>> _postsFuture; // => late = initialized before first use
@override
void initState() {
super.initState();
_postsFuture = fetchPosts(); // => Start fetching in initState
// => Assigned once; FutureBuilder won't re-fetch on rebuild
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('HTTP GET')),
body: FutureBuilder<List<Post>>(
future: _postsFuture, // => The Future to observe
builder: (context, snapshot) {
// snapshot.connectionState tracks the Future lifecycle
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator()); // => Loading
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}')); // => Error
}
// snapshot.data is non-null when connectionState is done and no error
final posts = snapshot.data!; // => ! asserts non-null
return ListView.builder(
itemCount: posts.length,
itemBuilder: (context, index) => ListTile(
leading: CircleAvatar(child: Text('${posts[index].id}')),
title: Text(posts[index].title),
subtitle: Text(posts[index].body,
maxLines: 1, overflow: TextOverflow.ellipsis),
),
);
},
),
);
}
}
// => Fetches 100 posts from JSONPlaceholder, shows loading/error/data statesKey Takeaway: Perform HTTP requests with http.get(Uri.parse(url)); always check response.statusCode; use typed model classes with fromJson factories; assign the Future in initState to prevent re-fetching on rebuilds.
Why It Matters: Assigning the Future in initState rather than directly in build is a critical performance pattern. If the Future is created in build, every parent rebuild creates a new Future and re-fetches data - causing infinite loading loops or redundant network calls. This mistake appears frequently in tutorials that skip initState, leading to production apps with excessive API calls.
Example 32: HTTP POST with JSON Body
http.post sends data to an API. Always set the Content-Type: application/json header when sending JSON. Use json.encode to serialize Dart objects, and handle both success and error responses with appropriate UI feedback.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() => runApp(const MaterialApp(home: CreatePostPage()));
class CreatePostPage extends StatefulWidget {
const CreatePostPage({super.key});
@override
State<CreatePostPage> createState() => _CreatePostPageState();
}
class _CreatePostPageState extends State<CreatePostPage> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _bodyController = TextEditingController();
bool _isLoading = false;
String? _responseMessage; // => null until request completes
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
Future<void> _submitPost() async {
if (!_formKey.currentState!.validate()) return;
setState(() { _isLoading = true; _responseMessage = null; });
try {
final response = await http.post(
Uri.parse('https://jsonplaceholder.typicode.com/posts'),
// Content-Type header tells server the body is JSON
headers: {'Content-Type': 'application/json; charset=UTF-8'},
// json.encode converts Dart Map to JSON string
body: json.encode({
'title': _titleController.text, // => POST body field: title
'body': _bodyController.text, // => POST body field: body
'userId': 1, // => POST body field: userId
}),
);
// => response.statusCode for creation is typically 201 Created
if (response.statusCode == 201) {
final data = json.decode(response.body) as Map<String, dynamic>;
setState(() {
_responseMessage = 'Created post with ID: ${data['id']}'; // => Server-assigned ID
});
} else {
setState(() {
_responseMessage = 'Error ${response.statusCode}: ${response.body}';
});
}
} catch (e) {
// Catch network errors (no connection, timeout, DNS failure)
setState(() => _responseMessage = 'Network error: $e');
} finally {
// finally always runs, whether try succeeded or catch ran
setState(() => _isLoading = false); // => Always hide loading
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('HTTP POST')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(labelText: 'Title',
border: OutlineInputBorder()),
validator: (v) => v!.isEmpty ? 'Title required' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _bodyController,
decoration: const InputDecoration(labelText: 'Body',
border: OutlineInputBorder()),
maxLines: 3, // => Multi-line text field
validator: (v) => v!.isEmpty ? 'Body required' : null,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submitPost,
child: _isLoading
? const CircularProgressIndicator()
: const Text('Create Post'),
),
),
if (_responseMessage != null) ...[
const SizedBox(height: 16),
Text(_responseMessage!, // => Show server response
style: TextStyle(
color: _responseMessage!.startsWith('Created')
? Colors.green : Colors.red, // => Green success, red error
)),
],
],
),
),
),
);
}
}Key Takeaway: Set Content-Type: application/json header for POST requests; use json.encode for the body; wrap the await in try/catch/finally to handle both API errors (non-201) and network failures (exceptions).
Why It Matters: The try/catch/finally pattern is essential for production HTTP code. catch handles network-level failures (no internet, DNS failure, timeout) that don’t produce HTTP responses. finally ensures loading state is always reset even if an exception propagates - preventing permanently spinning buttons. Missing either makes your UI unusable when network conditions degrade.
Example 33: FutureBuilder
FutureBuilder rebuilds its subtree reactively as a Future progresses through its states: waiting, done (with data), and done (with error). It eliminates the boilerplate of manually managing loading/error/data state variables in a StatefulWidget.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// Simulates fetching a user profile
Future<Map<String, dynamic>> fetchUser(int id) async {
await Future.delayed(const Duration(milliseconds: 800)); // => Simulate latency
final response = await http.get(
Uri.parse('https://jsonplaceholder.typicode.com/users/$id'),
);
if (response.statusCode != 200) throw Exception('User not found');
return json.decode(response.body) as Map<String, dynamic>;
}
void main() => runApp(const MaterialApp(home: UserProfilePage()));
class UserProfilePage extends StatefulWidget {
const UserProfilePage({super.key});
@override
State<UserProfilePage> createState() => _UserProfilePageState();
}
class _UserProfilePageState extends State<UserProfilePage> {
int _userId = 1; // => Currently viewed user ID
late Future<Map<String, dynamic>> _userFuture;
@override
void initState() {
super.initState();
_userFuture = fetchUser(_userId); // => Kick off initial fetch
}
void _loadUser(int id) {
setState(() {
_userId = id;
_userFuture = fetchUser(id); // => Reassign Future to trigger new build
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('User #$_userId Profile')),
body: Column(
children: [
// User selector row
Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [1, 2, 3].map((id) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
onPressed: () => _loadUser(id), // => Load different user
child: Text('User $id'),
),
)).toList(),
),
),
// FutureBuilder reacts to _userFuture changes
Expanded(
child: FutureBuilder<Map<String, dynamic>>(
future: _userFuture, // => Future to observe
builder: (context, snapshot) {
// ConnectionState.waiting: Future is still running
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// snapshot.hasError: Future completed with an exception
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, color: Colors.red, size: 48),
const SizedBox(height: 8),
Text('${snapshot.error}'), // => Display the error message
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _loadUser(_userId), // => Retry button
child: const Text('Retry'),
),
],
),
);
}
// snapshot.data is available: Future completed successfully
final user = snapshot.data!; // => The resolved value
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user['name'] as String,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(user['email'] as String,
style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 8),
Text('Phone: ${user['phone']}'),
Text('Website: ${user['website']}'),
Text('Company: ${(user['company'] as Map)['name']}'),
],
),
);
},
),
),
],
),
);
}
}
// => FutureBuilder shows loading, error with retry, and user data reactivelyKey Takeaway: FutureBuilder handles all three Future states (waiting, error, data) declaratively in one widget; always provide a retry mechanism in the error branch; reassigning the Future in setState triggers a fresh FutureBuilder observation.
Why It Matters: FutureBuilder is the idiomatic Flutter pattern for async UI - it eliminates five or more boolean state variables (isLoading, hasError, errorMessage, data, hasLoaded) and their corresponding setState calls. Production apps should always include a retry button in the error state; users on unreliable connections will encounter loading failures and need a way to recover without restarting the app.
Example 34: StreamBuilder
StreamBuilder rebuilds its subtree as new events arrive from a Stream. It is ideal for real-time data like WebSocket messages, database change feeds, or periodic polling. Unlike FutureBuilder, it remains active and continues updating as long as the stream emits events.
import 'package:flutter/material.dart';
// Simulate a real-time temperature sensor stream
Stream<double> temperatureStream() async* {
// async* creates a generator function that yields values asynchronously
double temp = 22.0; // => Starting temperature in Celsius
while (true) {
await Future.delayed(const Duration(seconds: 1)); // => Emit every second
// Simulate small temperature fluctuation
temp += (DateTime.now().millisecond % 10 - 5) * 0.1; // => Random ±0.5°C change
yield temp; // => yield emits value to stream
// => StreamBuilder receives each yielded value as a new event
}
}
void main() => runApp(const MaterialApp(home: TempMonitorPage()));
class TempMonitorPage extends StatefulWidget {
const TempMonitorPage({super.key});
@override
State<TempMonitorPage> createState() => _TempMonitorPageState();
}
class _TempMonitorPageState extends State<TempMonitorPage> {
// Keep the stream reference stable - creating it in build would restart it on every rebuild
final Stream<double> _tempStream = temperatureStream();
final List<double> _history = []; // => Store last readings for chart
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Real-time Temperature')),
body: StreamBuilder<double>(
stream: _tempStream, // => Stream to observe
builder: (context, snapshot) {
// Store new data in history
if (snapshot.hasData) {
_history.add(snapshot.data!); // => Append new reading to history
if (_history.length > 10) _history.removeAt(0); // => Keep last 10 readings
}
// ConnectionState.waiting: no events received yet
// ConnectionState.active: stream is running, events may have arrived
// ConnectionState.done: stream has closed (no more events)
final state = snapshot.connectionState;
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (state == ConnectionState.waiting)
const CircularProgressIndicator() // => Waiting for first event
else if (snapshot.hasError)
Text('Error: ${snapshot.error}') // => Stream emitted an error
else ...[
// Current temperature display
Text(
'${snapshot.data!.toStringAsFixed(1)}°C',
style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
snapshot.data! > 25 ? 'WARM' : snapshot.data! < 18 ? 'COOL' : 'NORMAL',
style: TextStyle(
fontSize: 18,
color: snapshot.data! > 25 ? Colors.orange
: snapshot.data! < 18 ? Colors.blue : Colors.teal,
),
),
// History list
const SizedBox(height: 24),
Text('Last ${_history.length} readings:'),
Wrap(
spacing: 8,
children: _history.map((t) =>
Chip(label: Text('${t.toStringAsFixed(1)}°'))).toList(),
),
],
],
),
);
},
),
);
}
}
// => Live temperature updates every second via StreamKey Takeaway: StreamBuilder stays active for the lifetime of its stream, rebuilding on every event; store the stream in a field (not in build) to prevent restarting it on parent rebuilds.
Why It Matters: StreamBuilder is the standard Flutter pattern for all real-time data - chat messages, stock tickers, IoT sensor readings, and live notifications. In web applications specifically, WebSocket connections and Server-Sent Events are naturally represented as streams. Creating the stream in build restarts it on every parent rebuild, potentially causing duplicate WebSocket connections and lost messages.
Group 13: Animations
Example 35: Implicit Animations
Implicit animations in Flutter automatically animate between old and new property values when you call setState. AnimatedContainer, AnimatedOpacity, AnimatedSwitcher, and AnimatedDefaultTextStyle are the most common implicit animation widgets - they require only a duration and handle all tweening internally.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ImplicitAnimationDemo()));
class ImplicitAnimationDemo extends StatefulWidget {
const ImplicitAnimationDemo({super.key});
@override
State<ImplicitAnimationDemo> createState() => _ImplicitAnimationDemoState();
}
class _ImplicitAnimationDemoState extends State<ImplicitAnimationDemo> {
bool _expanded = false; // => Toggle between two visual states
bool _visible = true; // => Controls opacity animation
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Implicit Animations')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// AnimatedContainer smoothly transitions all changed properties
AnimatedContainer(
duration: const Duration(milliseconds: 400), // => Animation takes 400ms
curve: Curves.easeInOut, // => Non-linear easing curve
width: _expanded ? 300 : 100, // => Animate width on state change
height: _expanded ? 200 : 100, // => Animate height on state change
decoration: BoxDecoration(
color: _expanded ? Colors.teal : Colors.blue, // => Animate color too
borderRadius: BorderRadius.circular(_expanded ? 24 : 8), // => Animate radius
),
alignment: Alignment.center,
child: Text(
_expanded ? 'Expanded' : 'Compact',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
),
),
// => Width, height, color, and border radius all animate simultaneously
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => setState(() => _expanded = !_expanded), // => Toggle state
child: Text(_expanded ? 'Collapse' : 'Expand'),
),
const SizedBox(height: 24),
// AnimatedOpacity fades in/out without layout changes
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: _visible ? 1.0 : 0.0, // => 1.0 = fully visible, 0.0 = invisible
child: const Text('I fade in and out!',
style: TextStyle(fontSize: 20)),
),
// => Widget stays in layout even when opacity is 0 (unlike Visibility)
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => setState(() => _visible = !_visible),
child: Text(_visible ? 'Hide' : 'Show'),
),
const SizedBox(height: 24),
// AnimatedSwitcher animates between different child widgets
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _expanded // => Key required for different widgets
? const Icon(Icons.expand_less, key: ValueKey('less'), size: 48)
: const Icon(Icons.expand_more, key: ValueKey('more'), size: 48),
// => Transitions between two different icons with a fade animation
// => key: ValueKey is required so Flutter knows these are different widgets
),
],
),
),
);
}
}Key Takeaway: Implicit animation widgets automatically tween between old and new property values when setState changes them; always provide duration and optionally curve; AnimatedSwitcher requires unique Key values on the children to detect which widget changed.
Why It Matters: Implicit animations add perceived quality to production apps with minimal code. A login button that smoothly expands when tapped, a card that gently fades in, an icon that transitions on state change - all of these can be implemented with AnimatedContainer or AnimatedSwitcher in a few lines. The Key requirement for AnimatedSwitcher is a common gotcha - without different keys, Flutter thinks the same widget changed its type and the animation does not play.
Example 36: Explicit Animations
Explicit animations use AnimationController for full control over playback: start, stop, reverse, repeat, and seek. They are essential for looping animations, sequenced animations, and animations triggered by user gestures. Tween defines value range; CurvedAnimation applies easing.
graph LR
A["AnimationController<br/>drives time 0.0 to 1.0"] --> B["CurvedAnimation<br/>applies easing curve"]
B --> C["Tween.animate<br/>maps 0.0-1.0 to typed values"]
C --> D["AnimatedBuilder<br/>rebuilds on each frame"]
style A fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
style B fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
style C fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
style D fill:#CC78BC,stroke:#000000,color:#FFFFFF,stroke-width:2px
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ExplicitAnimationDemo()));
class ExplicitAnimationDemo extends StatefulWidget {
const ExplicitAnimationDemo({super.key});
@override
State<ExplicitAnimationDemo> createState() => _ExplicitAnimationDemoState();
}
// SingleTickerProviderStateMixin provides the vsync for AnimationController
class _ExplicitAnimationDemoState extends State<ExplicitAnimationDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller; // => Drives the animation 0.0 → 1.0
late Animation<double> _scaleAnimation; // => Typed animation for scale
late Animation<double> _rotateAnimation; // => Typed animation for rotation
late Animation<Color?> _colorAnimation; // => Typed animation for color
@override
void initState() {
super.initState();
// AnimationController requires vsync to sync with display refresh
_controller = AnimationController(
duration: const Duration(seconds: 1), // => One cycle takes 1 second
vsync: this, // => this is the TickerProvider
);
// CurvedAnimation applies non-linear easing to the controller's linear 0-1 progress
final curved = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut, // => Bouncy elastic effect at end
);
// Tween.animate maps 0.0-1.0 to the Tween range and returns an Animation<T>
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.5).animate(curved);
// => When controller is 0.0, scale is 0.5; when 1.0, scale is 1.5
_rotateAnimation = Tween<double>(begin: 0, end: 2 * 3.14159).animate(_controller);
// => Rotates full circle (0 to 2π radians) linearly (no curve)
_colorAnimation = ColorTween(begin: Colors.blue, end: Colors.orange).animate(_controller);
// => Transitions from blue to orange as animation progresses
}
@override
void dispose() {
_controller.dispose(); // => Must dispose AnimationController
super.dispose(); // => Prevents memory leak from ticker
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Explicit Animation')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// AnimatedBuilder rebuilds whenever _controller ticks
AnimatedBuilder(
animation: _controller, // => Listen to controller ticks
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value, // => Current scale value (0.5-1.5)
child: Transform.rotate(
angle: _rotateAnimation.value, // => Current rotation in radians
child: Container(
width: 80,
height: 80,
color: _colorAnimation.value, // => Current interpolated color
child: child, // => child is built once, not on each tick
),
),
);
},
child: const Icon(Icons.star, color: Colors.white, size: 48),
// => child is passed to builder for optimization - it doesn't rebuild each frame
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => _controller.forward(), // => Play forward to end
child: const Text('Play'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => _controller.reverse(), // => Play backward to start
child: const Text('Reverse'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () => _controller.repeat(), // => Loop continuously
child: const Text('Loop'),
),
],
),
],
),
),
);
}
}
// => Icon scales, rotates, and changes color with elastic easingKey Takeaway: AnimationController drives time; Tween.animate maps 0-1 to typed values; AnimatedBuilder rebuilds on each tick; always dispose the AnimationController in dispose().
Why It Matters: Explicit animations are required for any animation that loops, reverses on command, or needs programmatic control. Loading spinners, progress indicators, pulse effects on notifications, and gesture-driven animations all require AnimationController. Forgetting to dispose the controller is the most common animation-related memory leak in Flutter - the ticker continues running even after the widget is removed from the tree.
Group 14: Theming and Responsive Layout
Example 37: ThemeData and Custom Themes
ThemeData configures the visual properties of an entire application. Material 3 uses ColorScheme.fromSeed to generate a harmonious palette from a single brand color. Custom themes ensure consistent colors, typography, and component styling across the entire app.
import 'package:flutter/material.dart';
// Define a branded theme using Material 3 ColorScheme
ThemeData buildBrandTheme({required bool isDark}) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF1565C0), // => Brand blue as seed
brightness: isDark ? Brightness.dark : Brightness.light,
// => Flutter generates a complete 30-color scheme from one seed
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
// Typography customization
textTheme: TextTheme(
headlineLarge: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: colorScheme.onSurface, // => Semantic color from scheme
),
bodyMedium: TextStyle(
fontSize: 14,
color: colorScheme.onSurface,
),
),
// Component-level theme overrides
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(120, 48), // => Minimum touch target size
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), // => Less rounded than Material 3 default
),
),
),
cardTheme: const CardThemeData(
elevation: 1, // => Subtle shadow on all cards
margin: EdgeInsets.symmetric(vertical: 4), // => Consistent card spacing
),
);
}
void main() {
runApp(const ThemeDemo());
}
class ThemeDemo extends StatefulWidget {
const ThemeDemo({super.key});
@override
State<ThemeDemo> createState() => _ThemeDemoState();
}
class _ThemeDemoState extends State<ThemeDemo> {
bool _isDark = false; // => Theme mode toggle
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: buildBrandTheme(isDark: false), // => Light theme
darkTheme: buildBrandTheme(isDark: true), // => Dark theme
themeMode: _isDark ? ThemeMode.dark : ThemeMode.light, // => Which to use
home: Scaffold(
appBar: AppBar(
title: const Text('Custom Theme'),
actions: [
IconButton(
icon: Icon(_isDark ? Icons.light_mode : Icons.dark_mode),
onPressed: () => setState(() => _isDark = !_isDark),
tooltip: 'Toggle theme',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Card(child: Padding( // => Uses cardTheme defaults
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Headline', style: Theme.of(context).textTheme.headlineLarge),
// => Uses custom headlineLarge with scheme-aware color
const SizedBox(height: 8),
Text('Body text with theme typography.',
style: Theme.of(context).textTheme.bodyMedium),
],
),
)),
const SizedBox(height: 16),
ElevatedButton( // => Uses elevatedButtonTheme defaults
onPressed: () {},
child: const Text('Brand Button'),
),
],
),
),
),
);
}
}
// => App switches between branded light and dark themesKey Takeaway: Define theme and darkTheme in MaterialApp using ColorScheme.fromSeed; use Theme.of(context).colorScheme and textTheme in widgets to automatically adapt to the current theme.
Why It Matters: A well-defined ThemeData eliminates hard-coded colors and font sizes throughout the codebase. When stakeholders request a brand color change, you update one seedColor and the entire app adjusts. Supporting dark mode via darkTheme is increasingly expected by users and required by major platform guidelines. Building theme support from the start is far cheaper than retrofitting it later.
Example 38: Responsive Layout with LayoutBuilder and MediaQuery
Flutter Web must adapt to screen sizes ranging from mobile phones to ultra-wide monitors. LayoutBuilder provides the parent’s constraints; MediaQuery.of(context).size provides the screen dimensions. Combine them with conditional widget rendering or GridView column counts.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ResponsiveDemo()));
class ResponsiveDemo extends StatelessWidget {
const ResponsiveDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Responsive Layout')),
body: LayoutBuilder(
// builder receives BoxConstraints with maxWidth and maxHeight
builder: (context, constraints) {
final width = constraints.maxWidth; // => Available width in logical pixels
// Define breakpoints as constants
const mobileBreak = 600.0; // => < 600: mobile
const tabletBreak = 900.0; // => 600-900: tablet
// => > 900: desktop
if (width < mobileBreak) {
return _MobileLayout(width: width); // => Single column layout
} else if (width < tabletBreak) {
return _TabletLayout(width: width); // => Two column layout
} else {
return _DesktopLayout(width: width); // => Three column layout
}
},
),
);
}
}
class _MobileLayout extends StatelessWidget {
final double width;
const _MobileLayout({required this.width});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text('Mobile (${width.toInt()}px)', style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
// Single column: stack cards vertically
for (var i = 1; i <= 6; i++)
Card(child: ListTile(
leading: CircleAvatar(child: Text('$i')),
title: Text('Item $i'),
subtitle: const Text('Single column layout'),
)),
],
);
}
}
class _TabletLayout extends StatelessWidget {
final double width;
const _TabletLayout({required this.width});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // => Two columns for tablet
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 2.5,
),
itemCount: 6,
itemBuilder: (context, index) => Card(
child: Center(child: Text('Tablet Item ${index + 1}')),
),
);
}
}
class _DesktopLayout extends StatelessWidget {
final double width;
const _DesktopLayout({required this.width});
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // => Three columns for desktop
crossAxisSpacing: 24,
mainAxisSpacing: 24,
childAspectRatio: 1.8,
),
itemCount: 6,
itemBuilder: (context, index) => Card(
child: Center(child: Text('Desktop Item ${index + 1}',
style: const TextStyle(fontSize: 16))),
),
);
}
}
// => Layout switches between 1, 2, and 3 columns based on available widthKey Takeaway: Use LayoutBuilder to get parent constraints for conditional layout; define breakpoints as constants (600/900 is a common Flutter convention); extract layout variants into separate widget classes for clarity.
Why It Matters: Flutter Web must handle everything from 375px mobile to 2560px ultrawide. Hard-coding a single layout causes overflow on narrow screens and awkward empty space on wide ones. LayoutBuilder is preferred over MediaQuery.of(context).size for layout decisions because it responds to the available space (which can be less than screen size in split-view or scrollable containers), making layouts more composable and correct.
Group 15: Routing
Example 39: GoRouter Configuration
GoRouter is the recommended Flutter router for web applications. It supports deep linking, URL-based navigation, path parameters, query parameters, and nested routes. Unlike Navigator 1.0, GoRouter properly synchronizes the browser URL bar with the current page.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; // => go_router: ^14.0.0 in pubspec.yaml
// Define the router configuration once at the top level
final router = GoRouter(
// initialLocation is the route shown when app first opens
initialLocation: '/', // => Start at home route
// routes is the list of top-level route configurations
routes: [
GoRoute(
path: '/', // => URL path to match
builder: (context, state) => const HomePage(), // => Widget to render
),
GoRoute(
path: '/products',
builder: (context, state) => const ProductListPage(),
),
GoRoute(
// :id is a path parameter - matches any value and captures it as 'id'
path: '/products/:id', // => e.g. /products/42
builder: (context, state) {
// state.pathParameters contains captured path segments
final id = state.pathParameters['id']!; // => '42' for /products/42
return ProductDetailPage(productId: id);
},
),
GoRoute(
path: '/search',
builder: (context, state) {
// state.uri.queryParameters reads query string values
final query = state.uri.queryParameters['q'] ?? ''; // => ?q=flutter
return SearchPage(query: query);
},
),
],
// errorBuilder renders when no route matches
errorBuilder: (context, state) => Scaffold(
appBar: AppBar(title: const Text('404')),
body: Center(child: Text('Page not found: ${state.uri}')),
),
);
void main() {
runApp(MaterialApp.router(
routerConfig: router, // => Use GoRouter instead of Navigator
));
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
// context.go replaces the current route (no back button)
onPressed: () => context.go('/products'), // => Navigate to /products
child: const Text('View Products'),
),
const SizedBox(height: 8),
ElevatedButton(
// context.push adds to history (back button available)
onPressed: () => context.push('/products/42'), // => Navigate to /products/42
child: const Text('Product Detail (Push)'),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => context.go('/search?q=flutter'), // => Query parameter
child: const Text('Search Flutter'),
),
],
),
),
);
}
}
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products')),
body: ListView.builder(
itemCount: 5,
itemBuilder: (context, i) => ListTile(
title: Text('Product ${i + 1}'),
onTap: () => context.go('/products/${i + 1}'), // => Navigate with path param
),
),
);
}
}
class ProductDetailPage extends StatelessWidget {
final String productId;
const ProductDetailPage({super.key, required this.productId});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Product $productId')),
body: Center(child: Text('Viewing product: $productId')),
);
}
}
class SearchPage extends StatelessWidget {
final String query;
const SearchPage({super.key, required this.query});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Search: "$query"')),
body: Center(child: Text('Results for "$query"')),
);
}
}
// => URL bar updates correctly for each navigation actionKey Takeaway: Configure GoRouter with GoRoute entries; use context.go for replacement navigation and context.push for stack navigation; path parameters use :paramName syntax; query parameters are read from state.uri.queryParameters.
Why It Matters: GoRouter is essential for production Flutter Web apps because it correctly handles browser history, back/forward buttons, and deep linking - things Navigator 1.0 cannot do on the web. Without a proper web-aware router, bookmarked URLs show blank pages, the browser back button breaks, and sharing links does not work. These are fundamental web application expectations that GoRouter satisfies.
Example 40: GoRouter with Shell Routes and Navigation Rail
ShellRoute wraps multiple child routes with persistent shell widgets like navigation rails or side menus. This pattern enables a consistent navigation chrome across all pages without re-rendering it on each navigation.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// NavigationRail: vertical navigation for tablet/desktop web
class ShellScaffold extends StatelessWidget {
final Widget child; // => Current route's page widget
const ShellScaffold({super.key, required this.child});
@override
Widget build(BuildContext context) {
// Determine which destination is currently active from the URL
final location = GoRouterState.of(context).uri.toString(); // => Current URL path
final selectedIndex = location.startsWith('/reports') ? 1
: location.startsWith('/settings') ? 2 : 0; // => Map URL to index
return Scaffold(
body: Row(
children: [
// NavigationRail: vertical tab bar for larger screens
NavigationRail(
selectedIndex: selectedIndex, // => Highlight current destination
onDestinationSelected: (index) {
// Navigate to the route corresponding to the selected index
switch (index) {
case 0: context.go('/dashboard'); break; // => Dashboard
case 1: context.go('/reports'); break; // => Reports
case 2: context.go('/settings'); break; // => Settings
}
},
labelType: NavigationRailLabelType.all, // => Show labels below icons
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard), // => Filled when selected
label: Text('Dashboard'),
),
NavigationRailDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: Text('Reports'),
),
NavigationRailDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: Text('Settings'),
),
],
),
const VerticalDivider(width: 1), // => Visual separator between rail and content
// child is the current route's page (fills remaining space)
Expanded(child: child), // => Page content fills rest of row
],
),
);
}
}
final shellRouter = GoRouter(
initialLocation: '/dashboard',
routes: [
// ShellRoute wraps child routes with the ShellScaffold
ShellRoute(
builder: (context, state, child) => ShellScaffold(child: child),
// => child is the currently matched inner route
routes: [
GoRoute(path: '/dashboard',
builder: (_, __) => const Center(child: Text('Dashboard Page'))),
GoRoute(path: '/reports',
builder: (_, __) => const Center(child: Text('Reports Page'))),
GoRoute(path: '/settings',
builder: (_, __) => const Center(child: Text('Settings Page'))),
],
),
],
);
void main() => runApp(MaterialApp.router(routerConfig: shellRouter));
// => NavigationRail persists across page changes, only content area updatesKey Takeaway: ShellRoute wraps child routes with a persistent UI shell; NavigationRail provides vertical navigation appropriate for web and tablet layouts; GoRouterState.of(context).uri determines the active route for selection highlighting.
Why It Matters: ShellRoute prevents the navigation chrome (sidebar, header) from rebuilding on every page navigation - only the content area changes. This matches how web applications work with a persistent sidebar. NavigationRail is the correct Material 3 widget for vertical navigation in web apps, replacing BottomNavigationBar (which belongs on mobile) for desktop-width screens.
Group 16: Web-Specific Features
Example 41: Platform Detection
kIsWeb from package:flutter/foundation.dart is true when running in a browser. defaultTargetPlatform identifies the operating system (Android, iOS, Windows, Linux, macOS). Use these to provide platform-appropriate behavior and UI.
import 'package:flutter/foundation.dart' show kIsWeb, defaultTargetPlatform, TargetPlatform;
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: PlatformDetectionDemo()));
class PlatformDetectionDemo extends StatelessWidget {
const PlatformDetectionDemo({super.key});
// Returns a human-readable description of the current platform
String get _platformDescription {
if (kIsWeb) { // => true when running in browser
// On web, defaultTargetPlatform tells us the OS the browser is running on
return 'Web browser on ${defaultTargetPlatform.name}';
}
return switch (defaultTargetPlatform) {
TargetPlatform.android => 'Android native app',
TargetPlatform.iOS => 'iOS native app',
TargetPlatform.macOS => 'macOS desktop app',
TargetPlatform.windows => 'Windows desktop app',
TargetPlatform.linux => 'Linux desktop app',
_ => 'Unknown platform',
};
}
// Platform-specific behavior example
Widget _buildContextMenu(BuildContext context) {
if (kIsWeb) {
// Web: right-click context menu is handled by the browser
// Provide keyboard shortcut hints instead
return const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('Web: Right-click triggers browser context menu.\n'
'Use Ctrl+C to copy, Ctrl+V to paste.'),
),
);
}
// Mobile: long press for context menu
return GestureDetector(
onLongPress: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Context menu opened')),
);
},
child: const Card(
child: Padding(
padding: EdgeInsets.all(12),
child: Text('Mobile: Long press for context menu'),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Platform Detection')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Platform info card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Current Platform:',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(_platformDescription), // => Shows platform description
const SizedBox(height: 8),
Text('kIsWeb: $kIsWeb'), // => true on web, false on native
Text('Platform: ${defaultTargetPlatform.name}'), // => OS name
],
),
),
),
const SizedBox(height: 16),
_buildContextMenu(context), // => Different UI per platform
const SizedBox(height: 16),
// Conditional feature availability
if (kIsWeb)
const Card(
child: ListTile(
leading: Icon(Icons.web),
title: Text('Web-only features available'),
subtitle: Text('URL manipulation, JS interop, LocalStorage'),
),
),
],
),
),
);
}
}
// => Shows platform-specific description and conditionally renders web-only UIKey Takeaway: kIsWeb is true in browsers; defaultTargetPlatform identifies the OS; use these to conditionally render platform-appropriate UI and enable/disable platform-specific features.
Why It Matters: Production Flutter apps targeting multiple platforms need conditional behavior for platform-specific capabilities. File picker implementations differ between web (HTML file input) and mobile (platform channels). Copy/paste, keyboard shortcuts, and hover states all need web-specific handling. Using kIsWeb at the right granularity keeps your codebase clean while correctly handling platform differences.
Example 42: URL Strategy for Clean URLs
By default, Flutter Web uses hash-based URLs (/#/path). For production web apps, you should use path-based URLs (/path) via usePathUrlStrategy(). This is required for SEO, link sharing, and integration with server-side routing.
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart'; // => url_launcher: ^6.3.0
// Call usePathUrlStrategy BEFORE runApp to configure clean URLs
// This function is from go_router and wraps flutter_web_plugins' setUrlStrategy
void main() {
// Note: usePathUrlStrategy() is called implicitly by GoRouter on web
// For explicit control, you can configure via GoRouter.optionURLReflectsImperativeAPIs
runApp(MaterialApp.router(
routerConfig: GoRouter(
routes: [
GoRoute(path: '/', builder: (_, __) => const UrlDemo()),
GoRoute(path: '/about', builder: (_, __) =>
const Scaffold(body: Center(child: Text('About Page')))),
],
),
));
}
class UrlDemo extends StatelessWidget {
const UrlDemo({super.key});
Future<void> _openExternal(String url) async {
// url_launcher opens URLs in new tab or external browser
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri,
webOnlyWindowName: '_blank', // => Open in new browser tab
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('URL Features')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Display current URL
Text('Current location: ${GoRouterState.of(context).uri}'),
// => Shows the current URL path (e.g., "/" or "/about")
const SizedBox(height: 16),
// Navigate and update URL bar
ElevatedButton(
onPressed: () => context.go('/about'), // => URL bar updates to /about
child: const Text('Go to /about'),
),
const SizedBox(height: 16),
// Open external URL in new tab
ElevatedButton.icon(
onPressed: () => _openExternal('https://flutter.dev'),
icon: const Icon(Icons.open_in_new),
label: const Text('Open Flutter.dev'),
),
// => Opens in new browser tab
const SizedBox(height: 16),
// Programmatic URL push (adds to history)
ElevatedButton(
onPressed: () => context.push('/about'), // => Adds /about to browser history
child: const Text('Push /about (with back button)'),
),
],
),
),
);
}
}
// => GoRouter keeps browser URL bar synchronized with app navigationKey Takeaway: GoRouter automatically manages URL synchronization; use context.go for replacement navigation (no back button) and context.push for stack navigation (adds to browser history); path-based URLs require server-side routing configuration for direct access.
Why It Matters: Hash URLs (/#/about) are invisible to search engine crawlers and look unprofessional in links. Path-based URLs require server configuration to redirect all routes to index.html (the SPA fallback), but are essential for production apps. Any nginx or CDN configuration serving Flutter Web must include try_files $uri /index.html to support direct URL access and browser refresh.
Example 43: Widget Tests
Flutter widget testing uses WidgetTester to pump widgets into a virtual screen, interact with them, and assert on the resulting widget tree. Widget tests run without a browser, making them fast and reliable in CI environments.
// test/widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// The widget to test
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
@override
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
int _count = 0;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text('Count: $_count', key: const Key('counter_text')), // => Key for finding in tests
ElevatedButton(
onPressed: () => setState(() => _count++),
child: const Text('Increment'),
),
],
);
}
}
void main() {
// testWidgets wraps each test with a WidgetTester
testWidgets('CounterWidget increments on button tap', (tester) async {
// pump inflates the widget into the virtual screen
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(body: CounterWidget()), // => Wrap in MaterialApp for theme/media
),
);
// => Widget tree is built and rendered
// Verify initial state
expect(find.text('Count: 0'), findsOneWidget); // => Text shows 0 initially
expect(find.text('Count: 1'), findsNothing); // => No widget shows 1 yet
// Interact: tap the Increment button
await tester.tap(find.text('Increment')); // => Simulate a tap gesture
await tester.pump(); // => Trigger a rebuild after setState
// Verify updated state
expect(find.text('Count: 1'), findsOneWidget); // => Text now shows 1
expect(find.text('Count: 0'), findsNothing); // => Old text is gone
// Tap again
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 2'), findsOneWidget); // => Count incremented to 2
// Finding by Key
final counterText = find.byKey(const Key('counter_text'));
expect(counterText, findsOneWidget); // => Widget with key exists
expect(tester.widget<Text>(counterText).data, 'Count: 2'); // => Verify text content
});
testWidgets('CounterWidget initial count is zero', (tester) async {
await tester.pumpWidget(
const MaterialApp(home: Scaffold(body: CounterWidget())),
);
expect(find.text('Count: 0'), findsOneWidget); // => Only one count: 0 widget
});
}
// => Run: flutter test test/widget_test.dartKey Takeaway: tester.pumpWidget inflates the widget; tester.tap simulates gestures; tester.pump triggers rebuilds; use find.text, find.byKey, and find.byType to locate widgets; expect asserts on finders.
Why It Matters: Widget tests are the backbone of Flutter quality assurance - they run in milliseconds, require no device or browser, and catch regressions before deployment. Adding Key values to important UI elements makes tests more stable and readable than searching by text which changes frequently. Teams that invest in widget test coverage maintain production apps with confidence across refactors.
Group 17: Custom Painting and Rendering
Example 44: CustomPainter
CustomPainter gives you direct access to the canvas for drawing primitives: lines, circles, rectangles, paths, and text. Use it when built-in widgets cannot achieve the required visual output - charts, custom shapes, artistic UI elements.
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: CustomPainterDemo()));
// CustomPainter subclass: implement paint() and shouldRepaint()
class ChartPainter extends CustomPainter {
final List<double> data; // => Data values to chart (0.0-1.0)
final Color barColor;
final Color labelColor;
const ChartPainter({
required this.data,
required this.barColor,
required this.labelColor,
});
@override
void paint(Canvas canvas, Size size) {
// Canvas provides low-level drawing operations
// size is the available space for this painter
final paint = Paint()
..color = barColor // => Set fill/stroke color
..style = PaintingStyle.fill; // => Fill shapes (vs stroke outline)
final barWidth = size.width / data.length - 8; // => Width of each bar with gap
for (int i = 0; i < data.length; i++) {
final x = i * (barWidth + 8) + 4; // => X position with spacing
final barHeight = data[i] * size.height * 0.8; // => Scale to 80% of canvas height
final y = size.height - barHeight; // => Y starts from bottom (canvas is top-down)
// drawRect draws a filled or stroked rectangle
canvas.drawRect(
Rect.fromLTWH(x, y, barWidth, barHeight), // => Left, Top, Width, Height
paint, // => Apply paint style
);
// Draw value label above each bar
final textPainter = TextPainter(
text: TextSpan(
text: '${(data[i] * 100).toInt()}%', // => Percentage label
style: TextStyle(color: labelColor, fontSize: 10),
),
textDirection: TextDirection.ltr,
)..layout(); // => Calculate text size
textPainter.paint(
canvas,
Offset(x + barWidth / 2 - textPainter.width / 2, y - 14), // => Center above bar
);
}
// Draw baseline
final baselinePaint = Paint()
..color = Colors.grey.shade400
..strokeWidth = 1;
canvas.drawLine(
Offset(0, size.height), // => Start of baseline (left)
Offset(size.width, size.height), // => End of baseline (right)
baselinePaint,
);
}
// shouldRepaint returns true when the painter should redraw
@override
bool shouldRepaint(ChartPainter oldDelegate) {
return data != oldDelegate.data || barColor != oldDelegate.barColor;
// => Only repaint when data or color changes - avoid unnecessary repaints
}
}
class CustomPainterDemo extends StatelessWidget {
const CustomPainterDemo({super.key});
static const _data = [0.7, 0.4, 0.9, 0.5, 0.8, 0.3, 0.6];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('CustomPainter')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: CustomPaint(
// CustomPaint hosts the CustomPainter and provides canvas
painter: ChartPainter(
data: _data,
barColor: Colors.blue.shade400,
labelColor: Colors.black87,
),
// size of the CustomPaint widget
size: const Size(300, 200), // => Canvas is 300x200 logical pixels
),
),
),
);
}
}
// => Bar chart rendered directly on CanvasKey Takeaway: Implement paint(Canvas, Size) to draw; implement shouldRepaint to control when repaints occur; use Canvas methods (drawRect, drawCircle, drawLine, drawPath) for all drawing; TextPainter renders text on the canvas.
Why It Matters: CustomPainter is how you build charts, gauges, waveforms, custom progress indicators, and any UI element that cannot be expressed as widget composition. Performance is critical - shouldRepaint returning false when data has not changed prevents redundant canvas redraws on every parent build, which would cause jank in data-heavy dashboards with frequent state updates.
Example 45: Hero Animations
Hero implements the shared element transition - a widget smoothly flies between two screens during navigation. Wrap matching elements in both source and destination screens with Hero using identical tag values. Flutter automatically animates the size and position.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: HeroListPage()));
// Products list with Hero source widgets
class HeroListPage extends StatelessWidget {
const HeroListPage({super.key});
static const products = [
{'id': 1, 'name': 'Blue Widget', 'color': Colors.blue},
{'id': 2, 'name': 'Orange Widget', 'color': Colors.orange},
{'id': 3, 'name': 'Teal Widget', 'color': Colors.teal},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Products (Hero Source)')),
body: ListView.builder(
itemCount: products.length,
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final product = products[index];
return Card(
child: ListTile(
leading: Hero(
// tag must uniquely identify this element and match the destination
tag: 'product-${product['id']}', // => Hero tag: "product-1", "product-2", etc.
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: product['color'] as Color,
borderRadius: BorderRadius.circular(8),
),
),
// => This 48x48 colored box will fly to the destination Hero
),
title: Text(product['name'] as String),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => HeroDetailPage(
id: product['id'] as int,
name: product['name'] as String,
color: product['color'] as Color,
),
),
),
),
);
},
),
);
}
}
// Product detail page with Hero destination widget
class HeroDetailPage extends StatelessWidget {
final int id;
final String name;
final Color color;
const HeroDetailPage({super.key, required this.id, required this.name, required this.color});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(name)),
body: Column(
children: [
// Hero destination: same tag, different size - Flutter animates between them
Hero(
tag: 'product-$id', // => Same tag as source Hero
child: Container(
width: double.infinity,
height: 200, // => Larger size on detail page
color: color, // => Same color matches source
),
// => Flutter animates from 48x48 list item to 200px tall detail header
),
Padding(
padding: const EdgeInsets.all(24),
child: Text(name,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: const Text('This hero widget flew from the list into this detail view. '
'The animation creates a sense of continuity between screens.'),
),
],
),
);
}
}
// => Colored box animates from list thumbnail size to full-width header on tapKey Takeaway: Wrap source and destination widgets with Hero using identical tag values; Flutter automatically animates size and position during navigation; tags must be unique across all currently visible Heroes.
Why It Matters: Hero animations create visual continuity between screens - users understand that the detail page shows the same item they tapped in the list. This reduces cognitive load and makes navigation feel fluid rather than jarring. The implementation requires zero animation code - just two Hero widgets with matching tags. This is Flutter’s most powerful developer experience win for navigation UX with minimal effort.
Group 18: Advanced State Patterns
Example 46: ValueNotifier and ValueListenableBuilder
ValueNotifier<T> is a lightweight ChangeNotifier specialized for a single value. ValueListenableBuilder rebuilds only when the ValueNotifier value changes. This pattern is ideal for fine-grained rebuilds without Provider or Riverpod.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ValueNotifierDemo()));
class ValueNotifierDemo extends StatefulWidget {
const ValueNotifierDemo({super.key});
@override
State<ValueNotifierDemo> createState() => _ValueNotifierDemoState();
}
class _ValueNotifierDemoState extends State<ValueNotifierDemo> {
// ValueNotifier holds a single typed value and notifies on change
final _counter = ValueNotifier<int>(0); // => Initial value: 0
final _message = ValueNotifier<String>('Hello'); // => Initial message
@override
void dispose() {
_counter.dispose(); // => Dispose like any ChangeNotifier
_message.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('ValueNotifier')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// ValueListenableBuilder rebuilds ONLY when _counter.value changes
ValueListenableBuilder<int>(
valueListenable: _counter, // => Listen to this ValueNotifier
builder: (context, count, child) {
// builder receives the current value directly
return Text('Count: $count', // => Rebuilt only when count changes
style: const TextStyle(fontSize: 48));
},
),
// => Only this Text rebuilds when counter changes
// => The rest of the widget tree (AppBar, Column, etc.) does NOT rebuild
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
_counter.value--; // => .value assignment triggers rebuild
// => No setState required - ValueNotifier handles notification
},
child: const Text('-'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () => _counter.value++, // => Increment current value
child: const Text('+'),
),
],
),
const SizedBox(height: 24),
// Two independent ValueNotifiers rebuild independently
ValueListenableBuilder<String>(
valueListenable: _message,
builder: (context, msg, _) => Text(msg,
style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => _message.value =
_message.value == 'Hello' ? 'World' : 'Hello', // => Toggle message
child: const Text('Toggle Message'),
),
],
),
),
);
}
}
// => Counter and message rebuild independently without rebuilding each otherKey Takeaway: ValueNotifier<T>.value = newValue triggers rebuilds in ValueListenableBuilder widgets; no setState is needed; each ValueListenableBuilder rebuilds independently when its notifier changes.
Why It Matters: ValueNotifier with ValueListenableBuilder is the most efficient Flutter state solution for isolated values - it rebuilds precisely the widgets that need updating without any framework overhead. This is ideal for animation counters, toggle states, and simple reactive values where full Provider/Riverpod setup is overkill. Production code that minimizes widget tree rebuilds delivers better frame rates on complex screens.
Example 47: PageView and TabBar
PageView provides swipeable pages commonly used for onboarding flows, image carousels, and tabbed content. TabController synchronizes a TabBar with a TabBarView (or PageView) for swipe-and-tap navigation.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: TabsDemo()));
class TabsDemo extends StatefulWidget {
const TabsDemo({super.key});
@override
State<TabsDemo> createState() => _TabsDemoState();
}
// SingleTickerProviderStateMixin provides the vsync for TabController
class _TabsDemoState extends State<TabsDemo> with SingleTickerProviderStateMixin {
late TabController _tabController; // => Manages tab selection
@override
void initState() {
super.initState();
_tabController = TabController(
length: 3, // => Number of tabs
vsync: this, // => Sync with display refresh
);
}
@override
void dispose() {
_tabController.dispose(); // => Must dispose TabController
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('TabBar Example'),
// TabBar in AppBar.bottom makes classic Material tab layout
bottom: TabBar(
controller: _tabController, // => Link to TabController
tabs: const [
Tab(icon: Icon(Icons.home), text: 'Home'),
Tab(icon: Icon(Icons.star), text: 'Featured'),
Tab(icon: Icon(Icons.person), text: 'Profile'),
],
// indicatorColor: Color of the underline indicator
indicatorColor: Colors.white, // => White indicator on colored AppBar
labelColor: Colors.white, // => Selected tab text color
unselectedLabelColor: Colors.white60, // => Unselected tab text color
),
),
body: TabBarView(
controller: _tabController, // => Same controller syncs with TabBar
children: [
// Tab content pages - swipe between them
_TabContent(
title: 'Home Tab',
color: Colors.blue.shade50,
icon: Icons.home,
),
_TabContent(
title: 'Featured Tab',
color: Colors.orange.shade50,
icon: Icons.star,
),
_TabContent(
title: 'Profile Tab',
color: Colors.teal.shade50,
icon: Icons.person,
),
],
),
);
}
}
class _TabContent extends StatelessWidget {
final String title;
final Color color;
final IconData icon;
const _TabContent({required this.title, required this.color, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
color: color,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.grey.shade600),
const SizedBox(height: 16),
Text(title, style: const TextStyle(fontSize: 24)),
],
),
),
);
}
}
// => Swipe or tap tabs to switch between three content pagesKey Takeaway: TabController synchronizes TabBar and TabBarView; both require the same controller instance; dispose TabController in dispose(); swiping TabBarView automatically updates TabBar selection.
Why It Matters: Tabbed navigation is fundamental in web and mobile applications - settings panels, analytics dashboards, and product pages all use tabs. The TabController pattern ensures the indicator and swipe stay synchronized without manual state management. Unlike BottomNavigationBar with IndexedStack, TabBarView lazily builds tab content on first visit rather than building all tabs upfront.
Group 19: Advanced Widgets
Example 48: InkWell, GestureDetector, and MouseRegion
InkWell provides Material tap ripple; GestureDetector handles any gesture without visual feedback; MouseRegion responds to hover on desktop and web. Understanding when to use each is important for cross-platform Flutter Web UX.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: GestureDemo()));
class GestureDemo extends StatefulWidget {
const GestureDemo({super.key});
@override
State<GestureDemo> createState() => _GestureDemoState();
}
class _GestureDemoState extends State<GestureDemo> {
String _gestureLog = 'No gesture'; // => Last detected gesture
bool _isHovered = false; // => Mouse hover state
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Gestures and Mouse')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// InkWell: Material tap with ripple effect
Card(
child: InkWell(
onTap: () => setState(() => _gestureLog = 'InkWell tapped'),
onLongPress: () => setState(() => _gestureLog = 'InkWell long pressed'),
borderRadius: BorderRadius.circular(12), // => Clip ripple to card shape
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('Tap me (InkWell with ripple)'),
),
),
),
const SizedBox(height: 12),
// GestureDetector: raw gesture without ink effect
GestureDetector(
onTap: () => setState(() => _gestureLog = 'GestureDetector tap'),
onDoubleTap: () => setState(() => _gestureLog = 'Double tap!'),
onHorizontalDragEnd: (details) => setState(() =>
_gestureLog = 'Swipe: velocity ${details.primaryVelocity?.toInt()}'),
child: Container(
padding: const EdgeInsets.all(16),
color: Colors.blue.shade100,
child: const Text('Tap, Double-tap, or Swipe (GestureDetector)'),
),
),
const SizedBox(height: 12),
// MouseRegion: hover effects for desktop/web
MouseRegion(
onEnter: (_) => setState(() => _isHovered = true), // => Mouse entered
onExit: (_) => setState(() => _isHovered = false), // => Mouse left
cursor: SystemMouseCursors.click, // => Hand cursor on hover
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _isHovered
? Colors.teal.shade200 // => Hover color
: Colors.teal.shade50, // => Normal color
borderRadius: BorderRadius.circular(8),
boxShadow: _isHovered
? [BoxShadow(color: Colors.teal.withOpacity(0.3),
blurRadius: 8, offset: const Offset(0, 2))] // => Hover shadow
: [],
),
child: const Text('Hover over me (MouseRegion)'),
),
),
const SizedBox(height: 24),
Text('Last gesture: $_gestureLog', // => Shows last detected gesture
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
],
),
),
);
}
}
// => Different gesture detectors with visual feedbackKey Takeaway: Use InkWell for Material tappable widgets with ripple; use GestureDetector for raw gesture detection; use MouseRegion for hover effects in Flutter Web - crucial for desktop-like UX.
Why It Matters: Flutter Web runs on desktop browsers where hover is a first-class interaction. Buttons and links should change cursor to pointer, cards should lift on hover, and navigation items should highlight on hover. MouseRegion with SystemMouseCursors.click and AnimatedContainer for hover effects reproduces the native web hover behavior that users expect. Missing hover feedback makes Flutter Web apps feel unpolished compared to traditional web applications.
Example 49: Dismissible and Reorderable Lists
Dismissible adds swipe-to-dismiss on list items, a common mobile and web interaction pattern. ReorderableListView allows drag-and-drop reordering. Both provide gesture-driven list interactions with appropriate animations.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: DismissibleDemo()));
class DismissibleDemo extends StatefulWidget {
const DismissibleDemo({super.key});
@override
State<DismissibleDemo> createState() => _DismissibleDemoState();
}
class _DismissibleDemoState extends State<DismissibleDemo> {
List<String> _items = List.generate(8, (i) => 'Item ${i + 1}'); // => 8 items
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Swipe to Dismiss')),
body: ReorderableListView.builder(
itemCount: _items.length,
// onReorder is called when user completes a drag
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--; // => Adjust for removal
final item = _items.removeAt(oldIndex); // => Remove from old position
_items.insert(newIndex, item); // => Insert at new position
});
},
itemBuilder: (context, index) {
final item = _items[index];
return Dismissible(
// key must uniquely identify each item for animation tracking
key: ValueKey(item), // => ValueKey uses item string for uniqueness
// direction: which direction to swipe for dismissal
direction: DismissDirection.endToStart, // => Swipe right-to-left only
// background shows behind the item during swipe
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
color: Colors.red.shade400, // => Red background on swipe
child: const Icon(Icons.delete, color: Colors.white),
),
// onDismissed removes the item from the list
onDismissed: (direction) {
setState(() => _items.removeAt(index)); // => Remove dismissed item
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$item deleted'),
action: SnackBarAction(label: 'UNDO', onPressed: () {
setState(() => _items.insert(index, item)); // => Restore on undo
}),
),
);
},
child: ListTile(
key: ValueKey(item), // => ReorderableListView also needs keys
leading: const Icon(Icons.drag_handle), // => Visual hint for drag
title: Text(item),
trailing: const Icon(Icons.chevron_right),
),
);
},
),
);
}
}
// => List items can be swiped to delete or dragged to reorderKey Takeaway: Dismissible requires a unique Key; background shows during the swipe gesture; onDismissed fires after the animation completes - remove the item from the list here; ReorderableListView requires key on every item and handles all drag logic.
Why It Matters: Swipe-to-dismiss and drag-to-reorder are table stakes for any to-do list, inbox, or ordered content management feature. Dismissible handles all animation, gesture recognition, and accessibility out of the box. The Undo snackbar pattern (always provided for destructive swipe actions) prevents user frustration from accidental dismissals and follows Material Design guidelines for destructive actions.
Example 50: FocusNode and Keyboard Handling
FocusNode programmatically controls which text field has keyboard focus. In web applications, keyboard navigation is critical for accessibility. FocusScope.of(context).nextFocus() moves focus through a form when users press Tab.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MaterialApp(home: FocusDemo()));
class FocusDemo extends StatefulWidget {
const FocusDemo({super.key});
@override
State<FocusDemo> createState() => _FocusDemoState();
}
class _FocusDemoState extends State<FocusDemo> {
final _firstNameFocus = FocusNode(); // => Focus for first name field
final _lastNameFocus = FocusNode(); // => Focus for last name field
final _emailFocus = FocusNode(); // => Focus for email field
final _firstNameCtrl = TextEditingController();
final _lastNameCtrl = TextEditingController();
final _emailCtrl = TextEditingController();
@override
void dispose() {
// Dispose all FocusNodes
_firstNameFocus.dispose();
_lastNameFocus.dispose();
_emailFocus.dispose();
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
_emailCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Focus and Keyboard')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
TextField(
controller: _firstNameCtrl,
focusNode: _firstNameFocus, // => Assign focus node to this field
decoration: const InputDecoration(
labelText: 'First Name',
border: OutlineInputBorder(),
),
// textInputAction controls the keyboard action button
textInputAction: TextInputAction.next, // => Shows "Next" key on mobile
onSubmitted: (_) {
// Move focus to next field when Enter/Next is pressed
FocusScope.of(context).requestFocus(_lastNameFocus);
// => requestFocus transfers keyboard focus to the specified node
},
),
const SizedBox(height: 12),
TextField(
controller: _lastNameCtrl,
focusNode: _lastNameFocus,
decoration: const InputDecoration(
labelText: 'Last Name',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
onSubmitted: (_) => FocusScope.of(context).requestFocus(_emailFocus),
),
const SizedBox(height: 12),
TextField(
controller: _emailCtrl,
focusNode: _emailFocus,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.done, // => Shows "Done" key on last field
onSubmitted: (_) {
// Unfocus (close keyboard) on last field's submit
FocusScope.of(context).unfocus(); // => Dismiss keyboard
},
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Programmatically focus the first field
FocusScope.of(context).requestFocus(_firstNameFocus);
// => Focuses first name field programmatically (e.g., on validation failure)
},
child: const Text('Focus First Field'),
),
const SizedBox(height: 8),
// KeyboardListener intercepts keyboard events
KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
FocusScope.of(context).unfocus(); // => Dismiss on Escape key
}
},
child: const SizedBox.shrink(), // => Invisible listener widget
),
],
),
),
);
}
}
// => Tab key and Next/Done actions move focus through the form fieldsKey Takeaway: Assign FocusNode instances to text fields; use FocusScope.of(context).requestFocus(node) to move focus programmatically; textInputAction.next with onSubmitted creates Tab-key-like navigation between fields.
Why It Matters: Keyboard navigation is a WCAG 2.1 Level AA requirement - users who cannot use a mouse must navigate entirely via keyboard. Forms without proper focus management force keyboard users to manually click each field. Tab-order through form fields, Enter to submit, and Escape to cancel are standard web conventions that FocusNode lets you implement correctly. This is non-negotiable for production web applications serving users with motor disabilities.
Example 51: Sliver Widgets and CustomScrollView
CustomScrollView composes multiple scroll effects using Sliver widgets. SliverAppBar creates collapsible headers; SliverList and SliverGrid are the Sliver equivalents of ListView and GridView. This enables advanced scroll effects found in news feeds and product pages.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: SliverDemo()));
class SliverDemo extends StatelessWidget {
const SliverDemo({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// CustomScrollView replaces the standard Scaffold body scroll
body: CustomScrollView(
slivers: [
// SliverAppBar: collapsible app bar that scrolls with content
SliverAppBar(
expandedHeight: 200, // => Height when fully expanded
pinned: true, // => AppBar stays visible (pinned) when collapsed
floating: false, // => Does not re-appear until scroll to top
flexibleSpace: FlexibleSpaceBar(
title: const Text('Sliver Demo'), // => Title shown in both states
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF0173B2), Color(0xFF029E73)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Center(
child: Icon(Icons.view_stream, size: 64, color: Colors.white),
),
),
),
),
// SliverToBoxAdapter: wraps a regular widget as a Sliver
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Featured Items',
style: Theme.of(context).textTheme.titleLarge),
),
),
// SliverGrid: grid of items in the scroll view
SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) => Card(
child: Center(child: Text('Grid ${index + 1}')),
),
childCount: 6, // => 6 grid items
),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
),
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('All Items', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
),
),
// SliverList: vertically scrolling list in the scroll view
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => ListTile(
title: Text('List Item ${index + 1}'),
subtitle: const Text('Sliver list item'),
),
childCount: 20, // => 20 list items
),
),
],
),
);
}
}
// => Collapsible header with mixed grid and list content in one scroll viewKey Takeaway: CustomScrollView composes multiple Sliver widgets in one scroll container; SliverAppBar with expandedHeight and pinned: true creates a collapsible header; SliverToBoxAdapter wraps normal widgets as Slivers.
Why It Matters: CustomScrollView with Slivers enables the collapsible AppBar + mixed grid/list patterns seen in e-commerce apps, news feeds, and content platforms. Without Slivers, achieving a collapsible header with different section layouts requires complex nested scroll views that fight each other. Slivers compose cleanly because they all participate in the same scroll physics. This is the architecture behind every modern product listing page.
Example 52: Overlay and OverlayEntry
Overlay is a Stack that stays above all other content. OverlayEntry inserts widgets into it. This is how Flutter implements tooltips, dropdowns, and autocomplete suggestions that must appear above all other content including scrollable lists.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: OverlayDemo()));
class OverlayDemo extends StatefulWidget {
const OverlayDemo({super.key});
@override
State<OverlayDemo> createState() => _OverlayDemoState();
}
class _OverlayDemoState extends State<OverlayDemo> {
OverlayEntry? _overlayEntry; // => Reference to dismiss later
void _showOverlay(BuildContext context) {
// Find the Overlay in the widget tree (provided by Navigator/MaterialApp)
final overlay = Overlay.of(context);
// Create an OverlayEntry with a builder
_overlayEntry = OverlayEntry(
builder: (context) => Positioned(
top: 100, // => Position from top of screen
left: 50,
right: 50,
child: Material( // => Material provides ink splash and theming
elevation: 8, // => Shadow depth
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Custom Overlay',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('This overlay floats above all other content'),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _dismissOverlay, // => Remove overlay on button tap
child: const Text('Close'),
),
],
),
),
),
),
);
// Insert the OverlayEntry into the Overlay
overlay.insert(_overlayEntry!); // => Overlay entry appears above all content
}
void _dismissOverlay() {
_overlayEntry?.remove(); // => Remove from Overlay
_overlayEntry = null; // => Clear the reference
}
@override
void dispose() {
_dismissOverlay(); // => Clean up overlay if widget disposes
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Overlay')),
body: Center(
child: ElevatedButton(
onPressed: () => _showOverlay(context),
child: const Text('Show Overlay'),
),
),
);
}
}
// => Custom overlay widget appears above all screen contentKey Takeaway: Overlay.of(context).insert(entry) adds widgets above all content; always store the OverlayEntry reference to call remove() later; clean up in dispose() to prevent orphaned overlays.
Why It Matters: Custom autocomplete dropdowns, custom tooltips, context menus, and in-app notification banners all require Overlay. These elements must appear above all scrollable content and other UI layers - only Overlay guarantees this. The critical production requirement is always removing the entry when the widget that created it is disposed, preventing zombie overlays that persist after navigation.
Example 53: Async Initialization with FutureBuilder in initState
Many production screens need async data before they can render. The pattern of assigning a Future to a field in initState and using FutureBuilder in build is the standard Flutter approach to async screen initialization without race conditions.
import 'package:flutter/material.dart';
// Simulated repository layer
class UserRepository {
Future<Map<String, String>> loadUserPreferences() async {
await Future.delayed(const Duration(milliseconds: 600)); // => Simulate DB read
return {
'theme': 'dark',
'language': 'en',
'fontSize': 'large',
};
}
Future<List<String>> loadRecentItems() async {
await Future.delayed(const Duration(milliseconds: 400)); // => Simulate network call
return ['Document A', 'Project B', 'Meeting C'];
}
}
// Aggregate multiple futures into one combined result
class ScreenData {
final Map<String, String> preferences;
final List<String> recentItems;
const ScreenData({required this.preferences, required this.recentItems});
}
Future<ScreenData> loadScreenData() async {
final repo = UserRepository();
// Future.wait runs multiple futures in parallel
final results = await Future.wait([
repo.loadUserPreferences(), // => Both run simultaneously
repo.loadRecentItems(),
]);
// => Future.wait returns a List with results in the same order as input
return ScreenData(
preferences: results[0] as Map<String, String>,
recentItems: results[1] as List<String>,
);
}
void main() => runApp(const MaterialApp(home: AsyncInitPage()));
class AsyncInitPage extends StatefulWidget {
const AsyncInitPage({super.key});
@override
State<AsyncInitPage> createState() => _AsyncInitPageState();
}
class _AsyncInitPageState extends State<AsyncInitPage> {
late Future<ScreenData> _dataFuture; // => Assigned once in initState
@override
void initState() {
super.initState();
_dataFuture = loadScreenData(); // => Start loading, no await needed here
// => initState cannot be async; assign the Future, don't await it
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Async Initialization')),
body: FutureBuilder<ScreenData>(
future: _dataFuture, // => Observes the pre-assigned Future
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Loading your data...'),
],
),
);
}
if (snapshot.hasError) {
return Center(child: Text('Error: ${snapshot.error}'));
}
final data = snapshot.data!; // => Both futures completed successfully
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Preferences:', style: Theme.of(context).textTheme.titleMedium),
...data.preferences.entries.map((e) =>
Text(' ${e.key}: ${e.value}')), // => Display each preference
const SizedBox(height: 16),
Text('Recent Items:', style: Theme.of(context).textTheme.titleMedium),
...data.recentItems.map((item) =>
ListTile(leading: const Icon(Icons.history), title: Text(item))),
],
),
);
},
),
);
}
}
// => Page loads two data sources in parallel, shows loading until both completeKey Takeaway: initState cannot be async - assign the Future synchronously; use Future.wait to parallelize multiple async operations; FutureBuilder observes the pre-assigned Future and handles all connection states.
Why It Matters: Running screen initialization futures in sequence doubles load time unnecessarily. Future.wait runs multiple async operations in parallel, cutting load time to the duration of the slowest operation. This pattern - parallel futures + FutureBuilder - is the production standard for screens that need multiple data sources before rendering. Every extra second of loading increases bounce rate on web applications.
Example 54: Scrolling Controllers and Scroll Physics
ScrollController provides programmatic control over scroll position - jump to top, animate to position, detect scroll events. Custom ScrollPhysics adjusts the feel of scrolling. These tools are necessary for infinite scroll, parallax effects, and scroll-aware UI.
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: ScrollControllerDemo()));
class ScrollControllerDemo extends StatefulWidget {
const ScrollControllerDemo({super.key});
@override
State<ScrollControllerDemo> createState() => _ScrollControllerDemoState();
}
class _ScrollControllerDemoState extends State<ScrollControllerDemo> {
late ScrollController _scrollController;
bool _showScrollToTop = false; // => Show FAB when scrolled down
double _currentOffset = 0; // => Current scroll position in pixels
@override
void initState() {
super.initState();
_scrollController = ScrollController(); // => Create controller
// Add listener to react to scroll position changes
_scrollController.addListener(() {
final offset = _scrollController.offset; // => Current scroll offset in pixels
setState(() {
_currentOffset = offset;
// Show scroll-to-top button when scrolled down 200px
_showScrollToTop = offset > 200; // => Threshold for showing FAB
});
});
}
@override
void dispose() {
_scrollController.dispose(); // => Must dispose to remove listeners
super.dispose();
}
void _scrollToTop() {
// animateTo smoothly scrolls to a position with duration and curve
_scrollController.animateTo(
0, // => Scroll to offset 0 (top)
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut, // => Smooth deceleration
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scroll Controller'),
// Show current scroll position in subtitle
bottom: PreferredSize(
preferredSize: const Size.fromHeight(24),
child: Text('Scroll offset: ${_currentOffset.toInt()}px',
style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
),
body: ListView.builder(
controller: _scrollController, // => Attach controller to ListView
itemCount: 50,
itemBuilder: (context, index) => ListTile(
title: Text('Item ${index + 1}'),
subtitle: Text('Scroll to see offset change'),
),
),
// Conditionally show scroll-to-top FAB
floatingActionButton: _showScrollToTop
? FloatingActionButton.small(
onPressed: _scrollToTop, // => Animate back to top
tooltip: 'Scroll to top',
child: const Icon(Icons.keyboard_arrow_up),
)
: null, // => null hides the FAB
);
}
}
// => Scroll offset tracked in real time; FAB appears after 200px, animates back to topKey Takeaway: ScrollController.addListener fires on every scroll frame; animateTo(0, duration, curve) smoothly scrolls to position; always dispose the controller to remove listeners; conditionally show scroll-to-top UI based on offset threshold.
Why It Matters: Scroll-to-top buttons are required UX for long content pages in web applications - WCAG 2.1 Success Criterion 2.4.1 (Bypass Blocks) benefits from this pattern. Tracking scroll offset also enables parallax effects, sticky headers via SliverPersistentHeader, and lazy-loading pagination (load more items when offset approaches maxScrollExtent). Infinite scroll is built on exactly this ScrollController.addListener pattern.
Example 55: Cached Network Images and Performance
Repeated network image loading degrades performance and wastes bandwidth. cached_network_image package provides disk and memory caching. Combined with RepaintBoundary, this pattern optimizes image-heavy Flutter Web screens like photo galleries and product catalogs.
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart'; // => cached_network_image: ^3.3.0
void main() => runApp(const MaterialApp(home: CachedImageDemo()));
class CachedImageDemo extends StatelessWidget {
const CachedImageDemo({super.key});
static const _urls = [
'https://picsum.photos/seed/a/300/200',
'https://picsum.photos/seed/b/300/200',
'https://picsum.photos/seed/c/300/200',
'https://picsum.photos/seed/d/300/200',
'https://picsum.photos/seed/e/300/200',
'https://picsum.photos/seed/f/300/200',
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cached Images')),
body: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.5,
),
itemCount: _urls.length,
itemBuilder: (context, index) {
return RepaintBoundary(
// RepaintBoundary isolates this widget's repaints from its siblings
// Without it, any animation or state change in ANY grid item repaints ALL items
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage(
imageUrl: _urls[index], // => URL to fetch and cache
// placeholder shown while image loads from network
placeholder: (context, url) => Container(
color: Colors.grey.shade200,
child: const Center(child: CircularProgressIndicator()),
),
// errorWidget shown if image fails to load
errorWidget: (context, url, error) => Container(
color: Colors.red.shade50,
child: const Icon(Icons.broken_image, color: Colors.red),
),
fit: BoxFit.cover, // => Scale to fill grid cell
// fadeInDuration: animate from placeholder to image
fadeInDuration: const Duration(milliseconds: 300), // => Smooth fade-in
// => Second time this URL is loaded, it reads from disk cache instantly
),
),
);
},
),
);
}
}
// => Images load once and are served from cache on subsequent visitsKey Takeaway: CachedNetworkImage caches images in disk and memory, preventing redundant network requests; RepaintBoundary isolates repaints to prevent full-grid redraws when one cell updates.
Why It Matters: A 6-cell image grid without caching performs 6 network requests on every visit. With CachedNetworkImage, subsequent loads are instantaneous disk reads. RepaintBoundary prevents the entire grid from repainting when one image loads - critical in grids where images load at different times. Together these optimizations reduce bandwidth by 80%+ and eliminate jank on image-heavy pages.