SafeArea & System UI
Understanding Safe Areas
Modern devices have notches, rounded corners, status bars, and navigation gestures that can obscure your app’s content. The SafeArea widget ensures your content is displayed within the visible, unobstructed portion of the screen. It automatically adds padding to avoid system UI elements.
SafeArea queries the device’s MediaQuery padding (which accounts for status bars, notches, home indicators, and system navigation) and applies that padding to its child. Without it, your text and buttons might be hidden behind the status bar or notch.
Basic SafeArea Usage
Wrapping your content in a SafeArea is the simplest way to avoid system UI overlap.
SafeArea Basic Example
class SafeAreaBasicPage extends StatelessWidget {
const SafeAreaBasicPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
// No AppBar — content starts at the very top
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'This text is safe from notches and status bars!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Expanded(
child: ListView.builder(
itemCount: 30,
itemBuilder: (context, index) => ListTile(
title: Text('Item \${index + 1}'),
),
),
),
],
),
),
);
}
}
Controlling SafeArea Edges
SafeArea provides boolean parameters to enable or disable padding on each edge individually. This gives you fine-grained control.
Selective SafeArea Edges
class SelectiveSafeArea extends StatelessWidget {
const SelectiveSafeArea({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Hero image goes edge-to-edge, including behind status bar
Container(
height: 300,
decoration: const BoxDecoration(
image: DecorationImage(
image: NetworkImage('https://picsum.photos/800/400'),
fit: BoxFit.cover,
),
),
child: SafeArea(
bottom: false, // Don't add bottom padding here
left: false,
right: false,
child: Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.all(8),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {},
),
),
),
),
),
// Content area needs bottom safe area
Expanded(
child: SafeArea(
top: false, // Already handled above
child: ListView(
padding: const EdgeInsets.all(16),
children: const [
Text(
'Article Title',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 12),
Text(
'Article content goes here. The bottom of this '
'list is protected from the home indicator on '
'devices that use gesture navigation.',
style: TextStyle(fontSize: 16),
),
],
),
),
),
],
),
);
}
}
The minimum Property
The minimum property sets a minimum padding that is always applied, even if the system padding is smaller. This is useful for ensuring consistent spacing.
SafeArea with Minimum Padding
SafeArea(
minimum: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Dashboard',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Expanded(
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
children: List.generate(
6,
(index) => Card(
child: Center(
child: Text('Card \${index + 1}'),
),
),
),
),
),
],
),
)
Scaffold with an AppBar, the scaffold already handles the top safe area for you. You only need SafeArea when you have no AppBar, or when you need to control bottom/side insets for content like floating buttons or bottom sheets.
SystemChrome: Controlling System UI
The SystemChrome class from dart:ui (exposed via services.dart) lets you control the appearance and behavior of system UI elements like the status bar, navigation bar, and screen orientation.
setSystemUIOverlayStyle
This method controls the appearance of the status bar and navigation bar — their colors, icon brightness, and more.
Styling the Status Bar and Navigation Bar
import 'package:flutter/services.dart';
class StyledSystemUIPage extends StatelessWidget {
const StyledSystemUIPage({super.key});
@override
Widget build(BuildContext context) {
// Set system UI style
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
// Status bar
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark, // Android
statusBarBrightness: Brightness.light, // iOS
// Navigation bar (Android)
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
systemNavigationBarDividerColor: Colors.grey,
),
);
return Scaffold(
body: SafeArea(
child: Center(
child: const Text(
'Custom System UI Style',
style: TextStyle(fontSize: 20),
),
),
),
);
}
}
statusBarBrightness controls the status bar style (light = dark icons, dark = light icons). On Android, statusBarIconBrightness is used instead. Always set both for cross-platform compatibility. Also note that statusBarColor only works on Android; iOS always uses the app’s background.
Using AnnotatedRegion for Declarative Styling
Instead of calling SystemChrome.setSystemUIOverlayStyle imperatively, you can use AnnotatedRegion for a declarative approach that ties the style to a widget’s lifecycle.
AnnotatedRegion Example
class AnnotatedRegionPage extends StatelessWidget {
const AnnotatedRegionPage({super.key});
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
statusBarBrightness: Brightness.dark,
),
child: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.indigo, Colors.blue],
),
),
child: const SafeArea(
child: Center(
child: Text(
'Light status bar icons on dark background',
style: TextStyle(
color: Colors.white,
fontSize: 18,
),
),
),
),
),
),
);
}
}
setEnabledSystemUIMode
This method controls which system UI overlays are visible. You can hide the status bar, navigation bar, or both for immersive experiences.
System UI Modes
class SystemUIModes extends StatelessWidget {
const SystemUIModes({super.key});
void _setEdgeToEdge() {
// Show all system UI but allow drawing behind them
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
);
}
void _setImmersive() {
// Hide all system UI; swipe from edge to temporarily show
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersive,
);
}
void _setImmersiveSticky() {
// Hide all system UI; swipe from edge shows translucent overlay
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.immersiveSticky,
);
}
void _setLeanBack() {
// Hide all system UI; tap anywhere to show
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.leanBack,
);
}
void _restoreNormal() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values, // Show all overlays
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('System UI Modes')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: _setEdgeToEdge,
child: const Text('Edge to Edge'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _setImmersive,
child: const Text('Immersive'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _setImmersiveSticky,
child: const Text('Immersive Sticky'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _setLeanBack,
child: const Text('Lean Back'),
),
const SizedBox(height: 24),
OutlinedButton(
onPressed: _restoreNormal,
child: const Text('Restore Normal'),
),
],
),
),
);
}
}
setPreferredOrientations
You can lock the app to specific orientations or allow all orientations programmatically.
Controlling Screen Orientation
class OrientationControlPage extends StatefulWidget {
const OrientationControlPage({super.key});
@override
State<OrientationControlPage> createState() =>
_OrientationControlPageState();
}
class _OrientationControlPageState extends State<OrientationControlPage> {
String _currentLock = 'All orientations';
Future<void> _lockPortrait() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
setState(() => _currentLock = 'Portrait only');
}
Future<void> _lockLandscape() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
setState(() => _currentLock = 'Landscape only');
}
Future<void> _unlockAll() async {
await SystemChrome.setPreferredOrientations(
DeviceOrientation.values,
);
setState(() => _currentLock = 'All orientations');
}
@override
void dispose() {
// Always restore orientation when leaving the page
SystemChrome.setPreferredOrientations(
DeviceOrientation.values,
);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Orientation Lock')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Current: \$_currentLock',
style: const TextStyle(fontSize: 18),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _lockPortrait,
child: const Text('Lock Portrait'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: _lockLandscape,
child: const Text('Lock Landscape'),
),
const SizedBox(height: 12),
OutlinedButton(
onPressed: _unlockAll,
child: const Text('Unlock All'),
),
],
),
),
);
}
}
Practical Example: Edge-to-Edge Media Viewer
Let’s build a fullscreen media viewer that goes edge-to-edge, uses immersive mode, styles the status bar, and handles safe areas correctly.
Fullscreen Media Viewer
class FullscreenMediaViewer extends StatefulWidget {
const FullscreenMediaViewer({super.key});
@override
State<FullscreenMediaViewer> createState() =>
_FullscreenMediaViewerState();
}
class _FullscreenMediaViewerState extends State<FullscreenMediaViewer> {
bool _showControls = true;
@override
void initState() {
super.initState();
_enterFullscreen();
}
@override
void dispose() {
_exitFullscreen();
super.dispose();
}
void _enterFullscreen() {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
void _exitFullscreen() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
void _toggleControls() {
setState(() => _showControls = !_showControls);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggleControls,
child: Stack(
fit: StackFit.expand,
children: [
// Media content (image placeholder)
Container(
color: Colors.grey.shade900,
child: const Center(
child: Icon(
Icons.play_circle_outline,
size: 80,
color: Colors.white54,
),
),
),
// Overlay controls
if (_showControls) ...[
// Top bar
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
bottom: false,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black54,
Colors.transparent,
],
),
),
child: Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
),
onPressed: () {
_exitFullscreen();
Navigator.pop(context);
},
),
const Expanded(
child: Text(
'Video Title',
style: TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
IconButton(
icon: const Icon(
Icons.more_vert,
color: Colors.white,
),
onPressed: () {},
),
],
),
),
),
),
// Bottom controls
Positioned(
bottom: 0,
left: 0,
right: 0,
child: SafeArea(
top: false,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black54,
Colors.transparent,
],
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Progress bar
SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
),
child: Slider(
value: 0.3,
onChanged: (v) {},
activeColor: Colors.red,
inactiveColor: Colors.white30,
),
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
const Text(
'1:23 / 4:56',
style: TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
IconButton(
icon: const Icon(
Icons.fullscreen_exit,
color: Colors.white,
),
onPressed: () {
_exitFullscreen();
Navigator.pop(context);
},
),
],
),
],
),
),
),
),
],
],
),
),
);
}
}
SafeArea protects your content from system UI elements like notches, status bars, and navigation indicators. Use the top, bottom, left, and right properties to control which edges are padded. SystemChrome gives you control over status bar styling, navigation bar appearance, system UI modes (immersive, edge-to-edge), and screen orientation. Use AnnotatedRegion for declarative system styling that automatically cleans up. Always restore system defaults in dispose() when using fullscreen or orientation locks.