Building a great Flutter UI goes beyond placing widgets on screen. It requires a coherent visual language applied consistently through themes, smooth response to user gestures, and a clear navigation structure that guides users through the app without confusion.
Styles
Styles control the visual appearance of widgets. Flutter separates visual properties from structural ones, making it easy to apply consistent styling across the entire app.
TextStyle
TextStyle applies fine-grained control over the appearance of text in any widget that renders a string.
Text(
'Hello, Flutter!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.blue,
letterSpacing: 1.2,
fontStyle: FontStyle.italic,
),
)
Common TextStyle properties:
| Property | Type | Description |
|---|---|---|
fontSize | double | Text size in logical pixels |
fontWeight | FontWeight | bold, normal, w500, etc. |
color | Color | Text color |
letterSpacing | double | Space between characters |
decoration | TextDecoration | Underline, strikethrough, etc. |
Theme
Themes apply consistent styling across an entire application without repeating style definitions in every widget. Define a ThemeData instance in MaterialApp and widgets automatically inherit its values.
MaterialApp(
theme: ThemeData(
primarySwatch: Colors.blue,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
textTheme: const TextTheme(
bodyMedium: TextStyle(fontSize: 16, color: Colors.black87),
headlineLarge: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.deepPurple,
foregroundColor: Colors.white,
),
),
),
home: const MyHomePage(),
)
Access the active theme anywhere in the widget tree:
final theme = Theme.of(context);
Text('Themed text', style: theme.textTheme.headlineMedium)
User Interaction
Flutter handles two broad categories of user interaction: input widgets and gesture detectors.
Input Widgets
Buttons interact through the onPressed callback. Text fields use a TextEditingController to read and manipulate the entered text.
final TextEditingController _emailController = TextEditingController();
TextField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email address',
hintText: 'user@example.com',
border: OutlineInputBorder(),
),
)
Gesture Detection
For gestures beyond simple button taps — swipes, long presses, drags — Flutter provides dedicated gesture widgets.
Gesture Detection Widgets
--------------------------
GestureDetector --> Low-level, full gesture access
InkWell --> Tap with Material ripple effect
Dismissible --> Swipe to dismiss (list items)
// GestureDetector — detect any gesture on any widget
GestureDetector(
onTap: () => print('Tapped'),
onLongPress: () => print('Long pressed'),
onDoubleTap: () => print('Double tapped'),
onHorizontalDragEnd: (details) => print('Swiped'),
child: Container(
color: Colors.blue,
width: 100,
height: 100,
),
)
// InkWell — tap with ripple animation
InkWell(
onTap: () => print('Tapped with ripple'),
child: const Padding(
padding: EdgeInsets.all(16),
child: Text('Tap me'),
),
)
Navigation
Navigation is the process of moving between screens (also called routes or pages) in a Flutter app. Flutter uses a Navigator — a widget that manages a stack of pages — as its core navigation primitive.
Navigation Patterns
-------------------
Stack Navigation --> Linear flow, push/pop screens
Tab Navigation --> Parallel sections, switch instantly
Drawer Navigation --> Side menu, app-wide destinations
Stack Navigation
Stack navigation behaves like a stack of cards. When you navigate forward, a new screen is pushed onto the stack. When the user goes back, the top screen is popped off.
Screen Stack (Stack Navigation)
--------------------------------
+------------------+
| Detail Screen | <-- Currently visible (top of stack)
+------------------+
| List Screen |
+------------------+
| Home Screen | <-- Bottom of stack
+------------------+
// Push a new screen onto the stack
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DetailScreen()),
);
// Pop the current screen off the stack (go back)
Navigator.pop(context);
// Replace the current screen (no back button)
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const NewScreen()),
);
Tab Navigation
Tab navigation enables users to switch between parallel sections of the app without losing their state in each section.
DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Tab Navigation'),
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.home), text: 'Home'),
Tab(icon: Icon(Icons.search), text: 'Search'),
Tab(icon: Icon(Icons.person), text: 'Profile'),
],
),
),
body: const TabBarView(
children: [
HomeTab(),
SearchTab(),
ProfileTab(),
],
),
),
)
Drawer Navigation
The Drawer widget slides in from the side of the screen, providing access to app-wide navigation destinations.
Scaffold(
appBar: AppBar(title: const Text('My App')),
drawer: Drawer(
child: ListView(
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blue),
child: Text('Menu', style: TextStyle(color: Colors.white, fontSize: 24)),
),
ListTile(
leading: const Icon(Icons.home),
title: const Text('Home'),
onTap: () => Navigator.pushReplacementNamed(context, '/'),
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => Navigator.pushReplacementNamed(context, '/settings'),
),
],
),
),
body: const Center(child: Text('Main content')),
)
Routing
Routing defines how your app maps navigation events to screens. Flutter provides two routing approaches: named routes and direct routes.
Named Routes
Named routes define a centralized map of route names to screen builders. This keeps navigation logic in one place and improves readability.
void main() {
runApp(MaterialApp(
initialRoute: '/',
routes: {
'/': (context) => const HomeScreen(),
'/products': (context) => const ProductListScreen(),
'/settings': (context) => const SettingsScreen(),
},
));
}
// Navigate using the route name
Navigator.pushNamed(context, '/products');
Direct Routes
Direct routes create the target widget inline at the call site. This is simpler for small apps or screens that require constructor parameters.
// Push with data passed to the target screen
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ProductDetailScreen(productId: '42'),
),
);
// Return data from a screen back to the caller
final result = await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SelectionScreen()),
);
Platform-Specific Route Transitions
Flutter provides two built-in page route classes that produce platform-native transition animations:
| Route Class | Transition Style | Platform |
|---|---|---|
MaterialPageRoute | Slide up from bottom | Android |
CupertinoPageRoute | Slide in from right | iOS |
Navigator.push(
context,
CupertinoPageRoute(builder: (context) => const DetailScreen()),
);
Navigation Best Practices
- Use named routes for apps with many screens to centralize navigation logic
- Use direct routes when passing complex objects to the next screen
- Prefer
pushReplacementfor login flows where the back button should not return to the previous screen - Use
Navigator.popUntilto return multiple levels up the stack at once - Consider dedicated routing packages like GoRouter or AutoRoute for large apps with complex navigation requirements