Адаптивний дизайн у Flutter
Створення застосунків, які коректно відображаються на різних пристроях — від телефонів до планшетів та десктопів — є важливим аспектом розробки. Flutter надає інструменти для створення адаптивного та responsive UI.
MediaQuery
MediaQuery надає інформацію про розміри екрану та налаштування пристрою.
class MediaQueryExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Отримання MediaQueryData
final mediaQuery = MediaQuery.of(context);
// Розміри екрану
final screenWidth = mediaQuery.size.width;
final screenHeight = mediaQuery.size.height;
// Відступи (safe area)
final padding = mediaQuery.padding;
final topPadding = padding.top; // Статус бар
final bottomPadding = padding.bottom; // Навігаційна панель
// Орієнтація
final orientation = mediaQuery.orientation;
final isLandscape = orientation == Orientation.landscape;
// Масштаб тексту
final textScaleFactor = mediaQuery.textScaleFactor;
// Темний режим
final platformBrightness = mediaQuery.platformBrightness;
final isDarkMode = platformBrightness == Brightness.dark;
// Доступність
final accessibleNavigation = mediaQuery.accessibleNavigation;
final boldText = mediaQuery.boldText;
return Scaffold(
body: SafeArea(
child: Column(
children: [
Text('Ширина: $screenWidth'),
Text('Висота: $screenHeight'),
Text('Орієнтація: ${isLandscape ? "Горизонтальна" : "Вертикальна"}'),
Text('Темний режим: $isDarkMode'),
],
),
),
);
}
}LayoutBuilder
LayoutBuilder надає розміри батьківського віджета для побудови адаптивного UI.
class LayoutBuilderExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Доступні розміри
final maxWidth = constraints.maxWidth;
final maxHeight = constraints.maxHeight;
// Адаптивний вибір макету
if (maxWidth < 600) {
return _buildMobileLayout();
} else if (maxWidth < 900) {
return _buildTabletLayout();
} else {
return _buildDesktopLayout();
}
},
);
}
Widget _buildMobileLayout() {
return ListView(
children: [
_buildCard('Картка 1'),
_buildCard('Картка 2'),
_buildCard('Картка 3'),
],
);
}
Widget _buildTabletLayout() {
return GridView.count(
crossAxisCount: 2,
children: [
_buildCard('Картка 1'),
_buildCard('Картка 2'),
_buildCard('Картка 3'),
_buildCard('Картка 4'),
],
);
}
Widget _buildDesktopLayout() {
return Row(
children: [
Expanded(flex: 1, child: _buildSidebar()),
Expanded(flex: 3, child: _buildContent()),
],
);
}
Widget _buildCard(String title) {
return Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(title),
),
);
}
Widget _buildSidebar() => Container(color: Colors.grey[200]);
Widget _buildContent() => Container(color: Colors.white);
}Breakpoints
Система breakpoints
class Breakpoints {
static const double mobile = 600;
static const double tablet = 900;
static const double desktop = 1200;
static const double largeDesktop = 1800;
}
enum DeviceType { mobile, tablet, desktop, largeDesktop }
class ResponsiveHelper {
static DeviceType getDeviceType(BuildContext context) {
final width = MediaQuery.of(context).size.width;
if (width < Breakpoints.mobile) {
return DeviceType.mobile;
} else if (width < Breakpoints.tablet) {
return DeviceType.tablet;
} else if (width < Breakpoints.desktop) {
return DeviceType.desktop;
} else {
return DeviceType.largeDesktop;
}
}
static bool isMobile(BuildContext context) =>
getDeviceType(context) == DeviceType.mobile;
static bool isTablet(BuildContext context) =>
getDeviceType(context) == DeviceType.tablet;
static bool isDesktop(BuildContext context) =>
getDeviceType(context) == DeviceType.desktop ||
getDeviceType(context) == DeviceType.largeDesktop;
static T value<T>(
BuildContext context, {
required T mobile,
T? tablet,
T? desktop,
}) {
final deviceType = getDeviceType(context);
switch (deviceType) {
case DeviceType.mobile:
return mobile;
case DeviceType.tablet:
return tablet ?? mobile;
case DeviceType.desktop:
case DeviceType.largeDesktop:
return desktop ?? tablet ?? mobile;
}
}
}
// Використання
Padding(
padding: EdgeInsets.all(
ResponsiveHelper.value(
context,
mobile: 8.0,
tablet: 16.0,
desktop: 24.0,
),
),
child: Text('Адаптивний відступ'),
)Responsive віджет
class ResponsiveWidget extends StatelessWidget {
final Widget mobile;
final Widget? tablet;
final Widget? desktop;
const ResponsiveWidget({
required this.mobile,
this.tablet,
this.desktop,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= Breakpoints.desktop) {
return desktop ?? tablet ?? mobile;
} else if (constraints.maxWidth >= Breakpoints.tablet) {
return tablet ?? mobile;
} else {
return mobile;
}
},
);
}
}
// Використання
ResponsiveWidget(
mobile: MobileHomeScreen(),
tablet: TabletHomeScreen(),
desktop: DesktopHomeScreen(),
)Flex та Expanded
class FlexibleLayoutExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isLandscape =
MediaQuery.of(context).orientation == Orientation.landscape;
return Flex(
direction: isLandscape ? Axis.horizontal : Axis.vertical,
children: [
Flexible(
flex: 1,
child: Container(color: Colors.red, height: 100),
),
Flexible(
flex: 2,
child: Container(color: Colors.green, height: 100),
),
Flexible(
flex: 1,
child: Container(color: Colors.blue, height: 100),
),
],
);
}
}Adaptive Widgets
Адаптивна навігація
class AdaptiveScaffold extends StatelessWidget {
final Widget body;
final int currentIndex;
final Function(int) onNavigationChanged;
const AdaptiveScaffold({
required this.body,
required this.currentIndex,
required this.onNavigationChanged,
});
@override
Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= Breakpoints.desktop;
if (isDesktop) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: currentIndex,
onDestinationSelected: onNavigationChanged,
labelType: NavigationRailLabelType.all,
destinations: [
NavigationRailDestination(
icon: Icon(Icons.home),
label: Text('Головна'),
),
NavigationRailDestination(
icon: Icon(Icons.search),
label: Text('Пошук'),
),
NavigationRailDestination(
icon: Icon(Icons.settings),
label: Text('Налаштування'),
),
],
),
VerticalDivider(thickness: 1, width: 1),
Expanded(child: body),
],
),
);
}
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: currentIndex,
onDestinationSelected: onNavigationChanged,
destinations: [
NavigationDestination(
icon: Icon(Icons.home),
label: 'Головна',
),
NavigationDestination(
icon: Icon(Icons.search),
label: 'Пошук',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Налаштування',
),
],
),
);
}
}Адаптивний діалог
Future<void> showAdaptiveDialog(BuildContext context) async {
final isDesktop = MediaQuery.of(context).size.width >= Breakpoints.tablet;
if (isDesktop) {
await showDialog(
context: context,
builder: (context) => Dialog(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 500, maxHeight: 400),
child: _DialogContent(),
),
),
);
} else {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => _DialogContent(),
),
);
}
}
class _DialogContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Text('Адаптивний діалог'),
Spacer(),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: Text('Закрити'),
),
],
),
);
}
}Responsive Grid
class ResponsiveGrid extends StatelessWidget {
final List<Widget> children;
const ResponsiveGrid({required this.children});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final crossAxisCount = _getCrossAxisCount(constraints.maxWidth);
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
childAspectRatio: 1.5,
),
itemCount: children.length,
itemBuilder: (context, index) => children[index],
);
},
);
}
int _getCrossAxisCount(double width) {
if (width < 600) return 1;
if (width < 900) return 2;
if (width < 1200) return 3;
return 4;
}
}
// Або з Wrap для автоматичного розміщення
class ResponsiveWrap extends StatelessWidget {
final List<Widget> children;
final double itemWidth;
const ResponsiveWrap({
required this.children,
this.itemWidth = 300,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 16,
children: children.map((child) {
return SizedBox(
width: itemWidth,
child: child,
);
}).toList(),
);
}
}FractionallySizedBox
// Розмір як відсоток від батька
FractionallySizedBox(
widthFactor: 0.8, // 80% ширини
heightFactor: 0.5, // 50% висоти
child: Container(color: Colors.blue),
)
// Адаптивна ширина контенту
class MaxWidthContainer extends StatelessWidget {
final Widget child;
final double maxWidth;
const MaxWidthContainer({
required this.child,
this.maxWidth = 1200,
});
@override
Widget build(BuildContext context) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: child,
),
);
}
}AspectRatio
// Зберігає пропорції
AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Colors.blue,
child: Center(child: Text('16:9')),
),
)
// Адаптивне відео
class ResponsiveVideo extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Менше співвідношення для мобільних
final aspectRatio = constraints.maxWidth < 600 ? 4 / 3 : 16 / 9;
return AspectRatio(
aspectRatio: aspectRatio,
child: Container(
color: Colors.black,
child: Center(
child: Icon(Icons.play_arrow, color: Colors.white, size: 64),
),
),
);
},
);
}
}Орієнтація екрану
class OrientationExample extends StatelessWidget {
@override
Widget build(BuildContext context) {
return OrientationBuilder(
builder: (context, orientation) {
return GridView.count(
crossAxisCount: orientation == Orientation.portrait ? 2 : 4,
children: List.generate(8, (index) {
return Card(
child: Center(child: Text('Елемент $index')),
);
}),
);
},
);
}
}
// Блокування орієнтації
class PortraitOnlyScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Встановити тільки портретну орієнтацію
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
return Scaffold(
body: Center(child: Text('Тільки портретна орієнтація')),
);
}
}Responsive Typography
class ResponsiveText extends StatelessWidget {
final String text;
final TextStyle? style;
const ResponsiveText(this.text, {this.style});
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
// Базовий розмір шрифту залежно від ширини екрану
double fontSize;
if (screenWidth < 600) {
fontSize = 14;
} else if (screenWidth < 900) {
fontSize = 16;
} else {
fontSize = 18;
}
return Text(
text,
style: (style ?? TextStyle()).copyWith(
fontSize: fontSize,
),
);
}
}
// Клас для responsive text styles
class ResponsiveTextStyles {
static TextStyle headline(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return TextStyle(
fontSize: width < 600 ? 24 : (width < 900 ? 32 : 40),
fontWeight: FontWeight.bold,
);
}
static TextStyle body(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return TextStyle(
fontSize: width < 600 ? 14 : (width < 900 ? 16 : 18),
);
}
}Platform-Specific UI
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
class PlatformWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (kIsWeb) {
return _buildWebUI();
}
if (Platform.isIOS) {
return _buildIOSUI();
}
if (Platform.isAndroid) {
return _buildAndroidUI();
}
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
return _buildDesktopUI();
}
return _buildDefaultUI();
}
Widget _buildWebUI() => Text('Web UI');
Widget _buildIOSUI() => Text('iOS UI');
Widget _buildAndroidUI() => Text('Android UI');
Widget _buildDesktopUI() => Text('Desktop UI');
Widget _buildDefaultUI() => Text('Default UI');
}Найкращі практики
Використовуйте LayoutBuilder замість MediaQuery для локальних рішень.
Створюйте систему breakpoints — уніфікуйте точки переходу.
Тестуйте на різних розмірах — використовуйте Device Preview.
Уникайте фіксованих розмірів — використовуйте відносні значення.
Враховуйте safe areas — використовуйте SafeArea.
Адаптуйте навігацію — використовуйте NavigationRail для десктопу.
Висновок
Адаптивний дизайн у Flutter вимагає комбінації різних інструментів: MediaQuery, LayoutBuilder, Flex віджетів та кастомних helper класів. Правильний підхід до responsive дизайну забезпечує оптимальний досвід користувача на всіх платформах та розмірах екранів.