Flutter Layouts & Responsive Design

SafeArea & System UI

40 min Lesson 15 of 16

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.

Key Concept: 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}'),
              ),
            ),
          ),
        ),
      ),
    ],
  ),
)
Tip: When you use 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),
          ),
        ),
      ),
    );
  }
}
Warning: On iOS, 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);
                              },
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ],
          ],
        ),
      ),
    );
  }
}
Summary: 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.