آموزش فلاتر (Flutter): توسعه اپلیکیشن برای صفحات نمایش با ابعاد مختلف


در این نوشته به معرفی صفحههای تطبیقپذیر (Adaptive) در SDK موبایل فلاتر میپردازیم. اپلیکیشنهای همراه باید از طیف گستردهای از اندازه دستگاهها، چگالی پیکسلها و جهتگیریهای مختلف پشتیبانی کنند. اپلیکیشنها باید بتوانند به طرز درستی مقیاسبندی شوند، تغییرات جهت افقی و عمودی را مدیریت کنند و دادهها را در همه این موارد حفظ کنند. فلاتر قابلیت انتخاب راهحلهای مختلف برای این چالشها را به جای ارائه یک روش منفرد در اختیار شما قرار میدهد.
راهحل اندروید برای نمایش روی صفحههای بزرگ
در اندروید برای نمایش اپلیکیشن روی صفحههای بزرگ مانند تبلت ها از فایلهای layout جایگزین استفاده میکنیم. در این فایلها میتوان کمینه عرض و جهتگیری عمودی/افقی را تعریف کرد.
بدین ترتیب باید یک فایل لیآوت برای گوشی و یک فایل برای تبلت تعریف کرد. سپس هر دو نوع جهتگیری را نیز برای هر دو سناریو در نظر گرفت. در ادامه این لیآوتها بر مبنای این که اپلیکیشن روی چه دستگاهی اجرا میشود، وهلهسازی میشوند. سپس میتوان بررسی کرد که کدام لیآوت (گوشی یا تبلت) فعال است و بر اساس آن وهله مربوطه از اپلیکیشن را ایجاد کرد.
در اغلب اپلیکیشنها از یک گردش master-detail برای مدیریت اندازههای بزرگ صفحه استفاده میشود که از فرگمنتها (Fragments) استفاده میکند. در ادامه به توضیح دقیق معنی گردش master-detail میپردازیم.
فرگمنتها در اندروید اساساً به کامپوننتهای دارای قابلیت استفاده مجدد گفته میشود که میتوان در هر اندازه صفحهای از آنها استفاده کرد. فرگمنتها لیآوتهای خاص خود را دارند و از کلاسهای جاوا/کاتلین برای کنترل دادهها برای چرخه عمر فرگمنت استفاده میشود. درک این چرخه به زمان زیادی نیاز دارد و برای پیادهسازی آن نیز کدهای زیادی لازم است.
در ادامه ابتدا به بررسی مدیریت جهتگیری دستگاه و سپس مدیریت اندازه صفحه در فلاتر میپردازیم.
کار با جهتگیری (Orientation) در فلاتر
زمانی که میخواهیم نوع جهتگیری را تنظیم کنیم، باید از عرض کامل صفحه استفاده کرده و بیشینه مقدار اطلاعات ممکن را نمایش دهیم.
در مثال زیر یک صفحه پروفایل ابتدایی را در دو جهت ایجاد کردهایم و بسته به جهتگیری دستگاه برای استفاده از بیشینه فضای ارائه شده صفحه، از لیآوت مربوطه استفاده شده است. کد این مثال را میتوانید در ادامه مشاهده کنید:
import 'package:flutter/material.dart'; class OrientationDemo extends StatefulWidget { @override _OrientationDemoState createState() => _OrientationDemoState(); } class _OrientationDemoState extends State<OrientationDemo> { var name = "Deven Joshi"; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: OrientationBuilder( builder: (context, orientation) { return orientation == Orientation.portrait ? _buildVerticalLayout() : _buildHorizontalLayout(); }, ), ); } Widget _buildVerticalLayout() { return Center( child: ListView( children: <Widget>[ Padding( padding: const EdgeInsets.all(32.0), child: Icon( Icons.account_circle, size: 100.0, ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( name, style: TextStyle(fontSize: 32.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), Padding( padding: const EdgeInsets.all(22.0), child: Text( "Demo Data", style: TextStyle(fontSize: 22.0), ), ), ], ), ); } Widget _buildHorizontalLayout() { return Center( child: Row( children: <Widget>[ Expanded( child: Column( children: <Widget>[ Padding( padding: const EdgeInsets.all(32.0), child: Icon( Icons.account_circle, size: 100.0, ), ), Padding( padding: const EdgeInsets.all(8.0), child: Text( name, style: TextStyle(fontSize: 32.0), ), ), ], ), ), Expanded( child: ListView( scrollDirection: Axis.vertical, children: List.generate(6, (n) { return Padding( padding: const EdgeInsets.all(8.0), child: Text( name, style: TextStyle(fontSize: 32.0), ), ); }), ), ), ], ), ); } }
در این کد، صفحه سادهای داریم که لیآوت های مختلفی برای حالت افقی (landscape) و عمودی (portrait) دارد. در ادامه تلاش میکنیم با ایجاد یک مثال تلاش میکنیم درک کنیم که چگونه میتوان در فلاتر بین لیآوتهای مختلف سوئیچ کرد؟
چگونه لیآوتهای مختلف طراحی میکنیم؟
از لحاظ مفهومی در فلاتر این کار به صورتی کاملاً مشابه اندروید اجرا میشود. ما دو لیآوت داریم، یکی برای حالت عمودی و یکی برای حالت افقی. البته در فلاتر ما فایل لیآوت نداریم؛ بلکه زمانی که دستگاه جهتگیری خود را تغییر میدهد، لیآوت را بازسازی میکنیم.
چگونه تغییرات جهتگیری را تشخیص میدهیم؟
در آغاز ما از یک ویجت به نام OrientationBuilder استفاده میکنیم.
OrientationBuilder ویجتی است که یک لیآوت یا بخشی از لیآوت را در زمان تغییر جهتگیری میسازد.
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: OrientationBuilder( builder: (context, orientation) { return orientation == Orientation.portrait ? _buildVerticalLayout() : _buildHorizontalLayout(); }, ), ); }
OrientationBuilder یک تابع builder دارد که لیآوت را میسازد. این تابع builder زمانی که جهتگیری تغییر مییابد، فراخوانی میشود. مقادیرِ ممکن برای این تابع برای حالتهای عمودی و افقی به ترتیب به صوت Orientation.portrait یا Orientation.landscape هستند. ما در این مقاله بررسی میکنیم که اگر صفحه در حالت عمودی باشد، یک لیآوت عمودی ایجاد کنیم و در غیر این صورت لیآوت افقی برای صفحه بسازیم.
()buildVerticalLayout_ و ()buildHorizontalLayout_ دو متدی هستند که برای ایجاد لیآوت های متناظر نوشته شدهاند. میتوان جهتگیری صفحه را در هر نقطه از کد (درون یا بیرون OrientationBuilder) با استفاده از کد زیر بررسی کرد:
MediaQuery.of(context).orientation
دقت کنید که وقتی بخواهیم از حالت پرتره دستگاه استفاده کنیم، از کد زیر کمک میگیریم:
setPreferredOrientations
ایجاد لیآوتهایی برای صفحههای بزرگتر در فلاتر
زمانی که با صفحههای بزرگ سر و کار داریم، معمولاً بهتر است که از همه فضای صفحه نمایش بهره بگیریم. سرراستترین روش برای این کار، ایجاد دو لیآوت متفاوت یا حتی صفحههایی برای تبلتها یا گوشیهای همراه است. در اینجا منظور ما از لیآوت بخش دیداری صفحه است و منظور از صفحه نیز لیآوت و همه کد بکاند متصل به آن است. با این وجود، این کار نیازمند نوشتن کدهای زیادی است که بخش بزرگی از آن نیز کدهای تکراری هستند.
برای حل این مشکل چه باید کرد؟
ابتدا نگاهی به پراستفادهترین کاربرد این حالت میاندازیم. اگر به مثال گردش «Master-Detail Flow» بازگردیم، میبینیم که وقتی این مفهوم وارد اپلیکیشنها میشود یک الگوی رایج میبینید که در آن با لیستی از آیتمها به صورت مستر (Master) مواجه هستید و زمانی که روی یک آیتم لیست کلیک میکنید به صفحه جزییات (Detail) دیگری هدایت میشوید. با در نظر گرفتن اپلیکیشن Gmail به عنوان یک مثال، میبینیم که در صفحه اصلی با لیستی از ایمیلها مواجه هستیم که وقتی روی هر یک از آنها میزنیم به صفحه جزییات میرویم که شامل محتوای ایمیل است:
برای این الگوی گردش یک مثال ایجاد میکنیم:

این اپلیکیشن لیستی از اعداد را در خود جای داده است که وقتی روی هر یک میزنیم عدد مربوطه را روی صفحه نمایش میدهد. در واقع ما یک لیست اصلی از اعداد داریم و نمای جزییات شامل نمایش عددی است که کلیک شده است. این فرایند همانند اپلیکیشن جیمیل است.
اگر از همین لیآوت در روی تبلت استفاده کنیم، بخشی بزرگی از صفحه به هدر میرود. بنابراین برای حل این مشکل چه باید کرد؟ ما میتوانیم هم لیست اصلی داشته باشیم و هم جزییات را روی همین صفحه طوری طراحی کنیم که از همه فضای صفحه استفاده شود.

اینک سؤال این است که برای این که دو صفحه نمایش مجزا ننویسیم، از چه راهحلی میتوانیم استفاده کنیم؟
بدین منظور ابتدا ببینیم که اندروید چه راهحلی دارد. اندروید در چنین مواقعی از کامپوننتهای با استفاده مجددی به نام فرگمنتها استفاده میکند. فرگمنتها لیستی به صورت Master و یک نمای Detail دارند. فرگمنت میتواند مجزا از صفحه نمایش تعریف شود و بدون تکرار مجدد کد به هر صفحهای اضافه شود.
بنابراین فرگمنت A یک لیست مستر و فرگمنت B فرگمنت جزییات است. در گوشیهای تلفن همراه و دستگاههای کوچکتر یا عرض لیآوت کمتر، یک کلیک روی آیتم لیست شما را به صفحه مجزایی میبرد، در حالی که در تبلتها شما در همان صفحه میمانید و فرگمنت جزییات تغییر مییابد. همچنین میتوان یک فرگمنت شبه تبلت را هنگام چرخش گوشی همراه به حالت افقی ایجاد کرد.
این همان مکانی است که نقطه قوت فلاتر هویدا میشود. هر ویجت در فلاتر ذاتاً قابلیت استفاده مجدد دارد، چون هر ویجت در فلاتر مانند یک فرگمنت است.
تنها کاری که باید انجام دهیم این است که دو ویجت تعریف کنیم. یکی برای لیست مستر و دیگری برای نمای جزییات. کافی است بررسی کنیم که دستگاه عرض کافی برای مدیریت هر دو بخش لیست و جزییات دارد یا نه. اگر چنین بود، میتوان از هر دو ویجت استفاده کرد. اگر دستگاه فضای کافی برای پشتیبانی از هر دو آنها نداشت؛ تنها لیست را نمایش میدهیم و محتوای جزییات را در صفحه مجزایی قرار میدهیم.
بنابراین ابتدا باید عرض صفحه را بررسی کنیم تا ببینیم آیا میتوانیم از لیآوت های عریض به جای کوچکتر استفاده کنیم یا نه. برای دریافت عرض صفحه میتوان از دستور زیر استفاده کرد:
MediaQuery.of(context).size.width
این دستور اندازه عرض و ارتفاع صفحه را بر مبنای dp تعیین میکنید. فرض کنید عرض کمینه ما برای سوئیچ کردن به لیآوت دوم برابر با 600 dp تعیین شده است.
جمعبندی نکات مطرح شده
- ما دو ویجت میسازیم که یکی برای نگهداری لیست مستر و دیگری برای قرار دادن نمای جزییات است.
- دو صفحه طراحی میکنیم که صفحه اول بررسی میکنیم، آیا عرض کافی برای نمایش هر دو ویجت وجود دارد یا نه.
- اگر عرض کافی موجود باشد، هر دو ویجت را به صفحه اضافه میکنیم و اگر چنین نباشد تنها زمانی که یکی از آیتمهای لیست کلیک شود به نمای جزییات میرویم.
کدنویسی
اینک مثالی را که در بخش بالاتر برای نمایش لیستی از اعداد مشاهده کردید کدنویسی میکنیم. در این مثال جزییات هر عدد در نمای جزییات نمایش مییابد. ابتدا دو ویجت میسازیم:
ویجت لیست (فرگمنت لیست)
typedef Null ItemSelectedCallback(int value); class ListWidget extends StatefulWidget { final int count; final ItemSelectedCallback onItemSelected; ListWidget( this.count, this.onItemSelected, ); @override _ListWidgetState createState() => _ListWidgetState(); } class _ListWidgetState extends State<ListWidget> { @override Widget build(BuildContext context) { return ListView.builder( itemCount: widget.count, itemBuilder: (context, position) { return Padding( padding: const EdgeInsets.all(8.0), child: Card( child: InkWell( onTap: () { widget.onItemSelected(position); }, child: Row( children: <Widget>[ Padding( padding: const EdgeInsets.all(16.0), child: Text(position.toString(), style: TextStyle(fontSize: 22.0),), ), ], ), ), ), ); }, ); } }
در این لیست تعداد آیتمهایی که باید نمایش یابند و همچنین callback هایی که هنگام کلیک هر آیتم باید فراخوانی شوند را نوشتهایم. این callback مهم است چون در این بخش است که تصمیم میگیریم آیا باید نمای جزییات را روی صفحههای بزرگ نمایش دهیم؛ یا این که روی صفحههای کوچکتر نمای جزییات را روی صفحه دیگری نمایش دهیم.
ما صرفاً یک کارت برای هر اندیس نمایش میدهیم و با یک پاسخ گرافیکی به تپها واکنش نشان میدهیم:
ویجت جزییات (فرگمنت جزییات)
class DetailWidget extends StatefulWidget { final int data; DetailWidget(this.data); @override _DetailWidgetState createState() => _DetailWidgetState(); } class _DetailWidgetState extends State<DetailWidget> { @override Widget build(BuildContext context) { return Container( color: Colors.blue, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ Text(widget.data.toString(), style: TextStyle(fontSize: 36.0, color: Colors.white),), ], ), ), ); } }
ویجت جزییات صرفاً عددی میگیرد و آن را به صوت دائمی نمایش میدهد. دقت کنید که اینها صفحه (Screen) نیستند؛ بلکه صرفاً ویجتهایی هستند که برای استفاده روی صفحه ساختهایم.
صفحه اصلی
class MasterDetailPage extends StatefulWidget { @override _MasterDetailPageState createState() => _MasterDetailPageState(); } class _MasterDetailPageState extends State<MasterDetailPage> { var selectedValue = 0; var isLargeScreen = false; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: OrientationBuilder(builder: (context, orientation) { if (MediaQuery.of(context).size.width > 600) { isLargeScreen = true; } else { isLargeScreen = false; } return Row(children: <Widget>[ Expanded( child: ListWidget(10, (value) { if (isLargeScreen) { selectedValue = value; setState(() {}); } else { Navigator.push(context, MaterialPageRoute( builder: (context) { return DetailPage(value); }, )); } }), ), isLargeScreen ? Expanded(child: DetailWidget(selectedValue)) : Container(), ]); }), ); } }
این بخش اصلی برنامه است. ما دو متغیر داریم: selectedValue که برای ذخیرهسازی آیتم انتخاب شدهی لیست استفاده میشود و isLargeScreen که یک مقدار بولی ساده است که برای ذخیرهسازی این نکته که عرض صفحه برای نمایش هر دو ویجت لیست و جزییات عرض کافی دارد یا نه، مورد استفاده قرار میگیرد.
همچنین یک OrientationBuilder داریم تا در صوتی که گوشی تلفن همراه در وضعیت افقی قرار گرفت و عرض کافی برای نمایش هر دو جزء را داشت، در این صورت لیآوت را به این صورت بازسازی کنیم.
ابتدا بررسی میکنیم که آیا عرض صفحه برای نمایش لیآوت ما کافی است:
if (MediaQuery.of(context).size.width > 600) { isLargeScreen = true; } else { isLargeScreen = false; }
بخش اصلی کد به صورت زیر است:
isLargeScreen? Expanded(child: DetailWidget(selectedValue)): Container(),
اگر صفحه بزرگ باشد، ویجت جزییات را نیز اضافه میکنیم و اگر چنین نباشد یک کانتینر خالی بازگشت میدهیم. از ویجتهای بازشدنی برای پر کردن عرض صفحه استفاده میکنیم، یا این که در صورت وجود صفحه بزرگ آن را به دو بخش متناسب تقسیم میکنیم. بنابراین ویجتهای بازشدنی امکان پر کردن نیمی از صفحه یا حتی درصد بیشتری از آن را با تعیین خصوصیت Flex ایجاد میکنند.
بخش مهم دوم به صورت زیر است:
if (isLargeScreen) { selectedValue = value; setState(() {}); } else { Navigator.push(context, MaterialPageRoute( builder: (context) { return DetailPage(value); }, )); }
یعنی اگر از لیآوت بزرگتر استفاده میشود، لازم نیست برای ویجت جزییات به صفحه دیگری برویم چون در همین صفحه قرار دارد. اگر صفحه کوچکتر باشد باید در این حالت به صفحه دیگری برویم، چون تنها لیست در صفحه جاری نمایش مییابد در نهایت کد نمای جزییات به صورت زیر است.
صفحه جزییات (برای صفحههای کوچکتر)
class DetailPage extends StatefulWidget { final int data; DetailPage(this.data); @override _DetailPageState createState() => _DetailPageState(); } class _DetailPageState extends State<DetailPage> { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: DetailWidget(widget.data), ); } }
تنها یک ویجت جزییات روی این صفحه قرار دارد و برای نمایش دادن دادهها روی صفحههای کوچکتر استفاده میشود.
اینک ما یک اپلیکیشن کامل داریم که بسته به اندازه صفحه و جهت قرارگیری آن تطبیق مییابد.
برخی نکات مهم دیگر
اگر بخواهید لیآوت های متفاوتی داشته باشید؛ اما لیآوتهایی شبیه فرگمنتها نداشته باشید، میتوانید آنها را به سادگی درون متد build بنویسید:
if (MediaQuery.of(context).size.width > 600) { isLargeScreen = true; } else { isLargeScreen = false; } return isLargeScreen? _buildTabletLayout() : _buildMobileLayout();
در ادامه دو متد برای ساخت لیآوت ها مینویسیم.
اگر میخواهید اپلیکیشن خود را صرفاً برای تبلت ها طراحی کنید، به جای بررسی عرض صفحه باید اندازه صفحه را از MediaQuery بگیرید و عرض واقعی صفحه را به جای عرض صفحه در جهتگیری خاص به دست آورید. زمانی که از عرض دریافتی از MediaQuery مستقیماً استفاده میکنیم، در واقع عرض صفحه را در حالت قرارگیری کنونی به دست میآوریم. بنابراین در حالت عمودی، طول گوشی به عنوان عرض در نظر گرفته میشود.
Size size = MediaQuery.of(context).size; double width = size.width > size.height ? size.height : size.width; if(width > 600) { // Do something for tablets here } else { // Do something for phones }
اگر این مطلب برای شما مفید بوده است، آموزشهایی که در ادامه آمدهاند نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامه نویسی اندروید
- آموزش نصب اندروید استودیو (Android Studio)
- مجموعه آموزشهای برنامهنویسی
- آموزش ساخت اولین پروژه در Android Studio
- گوگل فلاتر (Flutter) از صفر تا صد — ساخت اپلیکیشن به کمک ویجت
==