DartFlutter

How to Create Responsive UI in Flutter for All Devices

20250405 0005 Responsive Flutter UI Guide Simple Compose 01jr1dcxr9ec4tgcnpgwf68n8a 1024x683

Creating a great app isn’t just about beautiful UI—it’s about building responsive UI in Flutter that works perfectly across phones, tablets, foldables, and desktops. With Flutter running on multiple platforms, understanding responsive design techniques is essential for professional app development.

In this guide, you’ll learn the core tools Flutter provides for responsive layouts, see practical implementations for common patterns, and discover techniques for handling different screen sizes and orientations effectively.

Why Responsive Design Matters in Flutter

Flutter runs everywhere—Android, iOS, web, Windows, macOS, and Linux. This cross-platform capability is powerful, but it creates a challenge: your app must look good on a 4-inch phone, a 12-inch tablet, and a 27-inch desktop monitor.

Without responsive design, your app will face these problems:

  • Wasted space: Mobile layouts on tablets leave large empty areas
  • Overflow errors: Fixed-width layouts break on narrow screens
  • Poor usability: Touch targets sized for phones become tiny on desktop
  • Orientation issues: Portrait-only layouts break in landscape mode

Responsive UI ensures your app adapts intelligently to any screen, providing optimal user experience regardless of device.

Understanding Breakpoints

Before diving into widgets, establish consistent breakpoints for your app. These thresholds determine when layouts should change.

// Define breakpoints consistently across your app
class Breakpoints {
  static const double mobile = 600;
  static const double tablet = 900;
  static const double desktop = 1200;
  
  static bool isMobile(BuildContext context) =>
      MediaQuery.of(context).size.width < mobile;
      
  static bool isTablet(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    return width >= mobile && width < desktop;
  }
  
  static bool isDesktop(BuildContext context) =>
      MediaQuery.of(context).size.width >= desktop;
}

Common breakpoint conventions:

  • Mobile: Under 600px
  • Tablet: 600px to 900px
  • Desktop: 900px and above

These values work for most apps, but adjust based on your specific content and design requirements.

MediaQuery: Getting Screen Information

MediaQuery provides information about the current screen, including size, orientation, pixel density, and platform brightness settings.

Basic MediaQuery Usage

Widget build(BuildContext context) {
  final screenSize = MediaQuery.of(context).size;
  final orientation = MediaQuery.of(context).orientation;
  final padding = MediaQuery.of(context).padding;  // Safe area insets
  
  return Scaffold(
    body: Padding(
      padding: EdgeInsets.only(top: padding.top),  // Respect notches
      child: screenSize.width < 600
          ? MobileLayout()
          : TabletLayout(),
    ),
  );
}

Responsive Sizing with MediaQuery

Use MediaQuery for responsive sizing that scales with screen dimensions:

Widget build(BuildContext context) {
  final screenWidth = MediaQuery.of(context).size.width;
  
  // Calculate responsive values
  final horizontalPadding = screenWidth * 0.05;  // 5% of screen width
  final cardWidth = screenWidth < 600 
      ? screenWidth - 32  // Full width on mobile
      : 400;              // Fixed width on larger screens
  
  return Padding(
    padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
    child: SizedBox(
      width: cardWidth,
      child: ProductCard(),
    ),
  );
}

Handling Orientation Changes

Widget build(BuildContext context) {
  final orientation = MediaQuery.of(context).orientation;
  
  if (orientation == Orientation.portrait) {
    return Column(
      children: [
        HeaderSection(),
        Expanded(child: ContentSection()),
        BottomNavigation(),
      ],
    );
  } else {
    // Landscape: side-by-side layout
    return Row(
      children: [
        SideNavigation(),
        Expanded(child: ContentSection()),
      ],
    );
  }
}

Best for: Fine-tuned control based on exact screen dimensions and device characteristics.

LayoutBuilder: Constraint-Based Layouts

LayoutBuilder provides the parent widget's constraints, making it ideal for components that adapt based on available space rather than total screen size.

Basic LayoutBuilder Usage

Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      if (constraints.maxWidth < 600) {
        return MobileProductList();
      } else if (constraints.maxWidth < 900) {
        return TabletProductGrid(columns: 2);
      } else {
        return DesktopProductGrid(columns: 4);
      }
    },
  );
}

MediaQuery vs LayoutBuilder

The key difference: MediaQuery gives you the screen size, while LayoutBuilder gives you the available space for this specific widget.

// Consider a widget inside a drawer or dialog
// MediaQuery returns full screen width (e.g., 1200px)
// LayoutBuilder returns the drawer's width (e.g., 300px)

class ProductCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        // This responds to the card's container, not the screen
        final isCompact = constraints.maxWidth < 200;
        
        return isCompact
            ? CompactProductCard()
            : ExpandedProductCard();
      },
    );
  }
}

Best for: Reusable components that adapt to their container regardless of screen size.

Flexible, Expanded, and Spacer

These widgets work with Row and Column to create layouts that distribute space dynamically.

Expanded: Fill Available Space

Row(
  children: [
    // Fixed width
    SizedBox(width: 80, child: ProfileAvatar()),
    SizedBox(width: 16),
    // Takes remaining space
    Expanded(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Username', style: TextStyle(fontWeight: FontWeight.bold)),
          Text('Last seen recently'),
        ],
      ),
    ),
    // Fixed width
    IconButton(icon: Icon(Icons.more_vert), onPressed: () {}),
  ],
)

Flexible: Proportional Sizing

Row(
  children: [
    // Takes 2/3 of available space
    Flexible(
      flex: 2,
      child: TextField(decoration: InputDecoration(hintText: 'Search')),
    ),
    SizedBox(width: 16),
    // Takes 1/3 of available space
    Flexible(
      flex: 1,
      child: DropdownButton(...),
    ),
  ],
)

Spacer: Push Elements Apart

Row(
  children: [
    Text('Logo'),
    Spacer(),  // Pushes navigation to the right
    TextButton(child: Text('Home'), onPressed: () {}),
    TextButton(child: Text('About'), onPressed: () {}),
    TextButton(child: Text('Contact'), onPressed: () {}),
  ],
)

Best for: Dynamic sizing within rows and columns without explicit width calculations.

Wrap and GridView: Flowing Layouts

When content should flow and wrap based on available space, use Wrap or responsive GridView configurations.

Wrap Widget

Wrap(
  spacing: 8,      // Horizontal gap between items
  runSpacing: 8,   // Vertical gap between rows
  children: categories.map((category) => 
    Chip(
      label: Text(category.name),
      onDeleted: () => removeCategory(category),
    ),
  ).toList(),
)

Responsive GridView

Use GridView.builder with SliverGridDelegateWithMaxCrossAxisExtent for grids that adapt column count automatically:

GridView.builder(
  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
    maxCrossAxisExtent: 300,  // Maximum item width
    mainAxisSpacing: 16,
    crossAxisSpacing: 16,
    childAspectRatio: 0.75,   // Width to height ratio
  ),
  itemCount: products.length,
  itemBuilder: (context, index) => ProductCard(product: products[index]),
)

This grid automatically shows more columns on wider screens without explicit breakpoint logic.

Calculated Column Count

For more control, calculate column count based on screen width:

Widget build(BuildContext context) {
  return LayoutBuilder(
    builder: (context, constraints) {
      final columns = (constraints.maxWidth / 250).floor().clamp(1, 6);
      
      return GridView.builder(
        gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
          crossAxisCount: columns,
          mainAxisSpacing: 16,
          crossAxisSpacing: 16,
        ),
        itemCount: items.length,
        itemBuilder: (context, index) => ItemCard(item: items[index]),
      );
    },
  );
}

Best for: Tag clouds, product grids, and any content that should reflow based on available width.

AspectRatio and FittedBox

These widgets help maintain proportions across different screen sizes.

AspectRatio Widget

// Video player that maintains 16:9 ratio
AspectRatio(
  aspectRatio: 16 / 9,
  child: VideoPlayer(controller: _controller),
)

// Square profile picture
AspectRatio(
  aspectRatio: 1,
  child: ClipOval(
    child: Image.network(user.avatarUrl, fit: BoxFit.cover),
  ),
)

FittedBox: Scaling Content

// Scale text to fit available width
FittedBox(
  fit: BoxFit.scaleDown,
  child: Text(
    'Very Long Product Title That Might Overflow',
    style: TextStyle(fontSize: 24),
  ),
)

Best for: Media-heavy UIs, cards, and any content that should maintain specific proportions.

Building a Responsive Scaffold

Here's a complete example combining multiple techniques for a responsive app layout:

class ResponsiveScaffold extends StatelessWidget {
  final Widget body;
  final String title;
  
  const ResponsiveScaffold({
    required this.body,
    required this.title,
  });
  
  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final isDesktop = constraints.maxWidth >= 900;
        final isTablet = constraints.maxWidth >= 600 && constraints.maxWidth < 900;
        
        if (isDesktop) {
          return _DesktopLayout(title: title, body: body);
        } else if (isTablet) {
          return _TabletLayout(title: title, body: body);
        } else {
          return _MobileLayout(title: title, body: body);
        }
      },
    );
  }
}

class _DesktopLayout extends StatelessWidget {
  final String title;
  final Widget body;
  
  const _DesktopLayout({required this.title, required this.body});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Row(
        children: [
          // Permanent side navigation
          NavigationRail(
            extended: true,
            destinations: _navigationDestinations,
            selectedIndex: 0,
            onDestinationSelected: (index) {},
          ),
          VerticalDivider(thickness: 1, width: 1),
          // Main content area
          Expanded(
            child: Column(
              children: [
                _DesktopAppBar(title: title),
                Expanded(child: body),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _MobileLayout extends StatelessWidget {
  final String title;
  final Widget body;
  
  const _MobileLayout({required this.title, required this.body});
  
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      drawer: NavigationDrawer(children: _drawerItems),
      body: body,
      bottomNavigationBar: BottomNavigationBar(
        items: _bottomNavItems,
        currentIndex: 0,
        onTap: (index) {},
      ),
    );
  }
}

Responsive Typography

Text sizes should also adapt to screen size for optimal readability:

class ResponsiveText extends StatelessWidget {
  final String text;
  final TextStyle? baseStyle;
  
  const ResponsiveText(this.text, {this.baseStyle});
  
  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    
    // Scale factor based on screen width
    double scaleFactor;
    if (screenWidth < 600) {
      scaleFactor = 1.0;
    } else if (screenWidth < 900) {
      scaleFactor = 1.1;
    } else {
      scaleFactor = 1.2;
    }
    
    final style = (baseStyle ?? Theme.of(context).textTheme.bodyLarge)!
        .copyWith(fontSize: (baseStyle?.fontSize ?? 16) * scaleFactor);
    
    return Text(text, style: style);
  }
}

Testing Responsive Layouts

Flutter provides several tools for testing responsive designs:

  • Device Preview package: Test multiple device sizes simultaneously
  • Chrome DevTools: Run in web mode and resize the browser
  • Android Studio: Create emulators with different screen sizes
  • Widget tests: Test layouts at specific constraint sizes
// Widget test for responsive behavior
testWidgets('shows mobile layout on small screens', (tester) async {
  tester.binding.window.physicalSizeTestValue = Size(400, 800);
  tester.binding.window.devicePixelRatioTestValue = 1.0;
  
  await tester.pumpWidget(MaterialApp(home: ResponsiveScaffold(...)));
  
  expect(find.byType(BottomNavigationBar), findsOneWidget);
  expect(find.byType(NavigationRail), findsNothing);
});

Common Mistakes to Avoid

Hardcoding dimensions: Avoid fixed pixel values that don't adapt. Use relative sizing or constraints.

Ignoring safe areas: Always account for notches, status bars, and navigation bars using SafeArea or MediaQuery.padding.

Testing only one size: Test on multiple screen sizes during development, not just at the end.

Overflow errors: Use Expanded, Flexible, or scrollable widgets to prevent overflow on small screens.

Conclusion

Responsive UI in Flutter is essential for apps that run across multiple platforms. By combining MediaQuery, LayoutBuilder, Flexible widgets, and responsive grids, you can create interfaces that adapt beautifully to any screen size.

Start with clear breakpoints, test on multiple devices, and use the right tool for each situation—MediaQuery for screen-level decisions and LayoutBuilder for component-level adaptation.

For more Flutter UI techniques, see our guides on Custom Fonts in Flutter and State Management in Flutter. You can also check the official Flutter responsive UI guide for additional patterns.

1 Comment

Leave a Comment