1. บทนำ
Material Components (MDC) ช่วยให้นักพัฒนานำดีไซน์ Material มาใช้ MDC สร้างโดยทีมวิศวกรและนักออกแบบ UX ที่ Google โดยมีคอมโพเนนต์ UI ที่สวยงามและใช้งานได้หลายสิบอย่างและพร้อมใช้งานสำหรับ Android, iOS, เว็บ และ Flutter.material.io/develop |
ใน Codelab MDC-103 คุณได้ปรับแต่งสี ระดับความสูง การพิมพ์ และรูปร่างของ Material Components (MDC) เพื่อจัดรูปแบบแอป
คอมโพเนนต์หนึ่งในระบบดีไซน์ Material ทำงานชุดหนึ่งที่กำหนดไว้ล่วงหน้าและมีลักษณะเฉพาะบางอย่าง เช่น ปุ่ม อย่างไรก็ตาม ปุ่มเป็นมากกว่าวิธีสำหรับให้ผู้ใช้ทำงาน แต่ยังเป็นการแสดงออกทางภาพของรูปร่าง ขนาด และสีที่ช่วยให้ผู้ใช้ทราบว่าเป็นการโต้ตอบ และจะมีบางอย่างเกิดขึ้นเมื่อมีการแตะหรือคลิก
หลักเกณฑ์ดีไซน์ Material จะอธิบายส่วนประกอบต่างๆ จากมุมมองของนักออกแบบ โดยจะอธิบายฟังก์ชันพื้นฐานต่างๆ ที่พร้อมให้ใช้งานในแพลตฟอร์มต่างๆ และองค์ประกอบที่เป็นส่วนประกอบของแต่ละส่วน ตัวอย่างเช่น ฉากหลังประกอบด้วยเลเยอร์ย้อนกลับและเนื้อหา เลเยอร์หน้าและเนื้อหา กฎการเคลื่อนไหว และตัวเลือกการแสดงผล องค์ประกอบแต่ละอย่างเหล่านี้สามารถปรับแต่งตามความต้องการ กรณีการใช้งาน และเนื้อหาของแต่ละแอปได้
สิ่งที่คุณจะสร้าง
ใน Codelab นี้ คุณจะเปลี่ยน UI ในแอป Shrine เป็นงานนำเสนอ 2 ระดับที่เรียกว่า "ฉากหลัง" ฉากหลังมีเมนูที่แสดงหมวดหมู่ที่เลือกได้ซึ่งใช้เพื่อกรองผลิตภัณฑ์ที่แสดงในตารางกริดแบบอสมมาตร ใน Codelab นี้ คุณจะใช้สิ่งต่อไปนี้
- รูปร่าง
- การเคลื่อนไหว
- วิดเจ็ต Flutter (ที่คุณเคยใช้ใน Codelab ก่อนหน้านี้)
Android | iOS |
คอมโพเนนต์และระบบย่อยของ Material Flutter ใน Codelab นี้
- รูปร่าง
โปรดให้คะแนนประสบการณ์การใช้งานการพัฒนา Flutter ของคุณ
2. ตั้งค่าสภาพแวดล้อมในการพัฒนาซอฟต์แวร์ Flutter
ห้องทดลองนี้ต้องมีซอฟต์แวร์ 2 ประเภท ได้แก่ Flutter SDK และเครื่องมือแก้ไข
คุณเรียกใช้ Codelab ได้โดยใช้อุปกรณ์ต่อไปนี้
- อุปกรณ์ Android หรือ iOS ที่เชื่อมต่อกับคอมพิวเตอร์และตั้งค่าเป็นโหมดนักพัฒนาซอฟต์แวร์
- เครื่องมือจำลอง iOS (ต้องติดตั้งเครื่องมือ Xcode)
- โปรแกรมจำลอง Android (ต้องตั้งค่าใน Android Studio)
- เบราว์เซอร์ (การแก้ไขข้อบกพร่องต้องใช้ Chrome)
- เป็นแอปพลิเคชัน Windows, Linux หรือ macOS บนเดสก์ท็อป คุณต้องพัฒนาบนแพลตฟอร์มที่คุณวางแผนจะทำให้ใช้งานได้ ดังนั้นหากต้องการพัฒนาแอป Windows บนเดสก์ท็อป คุณต้องพัฒนาบน Windows เพื่อเข้าถึงเชนบิลด์ที่เหมาะสม มีข้อกำหนดเฉพาะระบบปฏิบัติการที่ครอบคลุมรายละเอียดใน docs.flutter.dev/desktop
3. ดาวน์โหลดแอปเริ่มต้นสำหรับ Codelab
ต้องดำเนินการต่อจาก MDC-103 ใช่ไหม
หากคุณดำเนินการ MDC-103 เสร็จสมบูรณ์แล้ว โค้ดของคุณก็จะพร้อมใช้งานสำหรับ Codelab นี้ ข้ามไปยังขั้นตอน: เพิ่มเมนูฉากหลัง
ต้องเริ่มใหม่ตั้งแต่ต้นใช่ไหม
แอปเริ่มต้นอยู่ในไดเรกทอรี material-components-flutter-codelabs-104-starter_and_103-complete/mdc_100_series
...หรือโคลนโมเดลจาก GitHub
หากต้องการโคลน Codelab นี้จาก GitHub ให้เรียกใช้คำสั่งต่อไปนี้
git clone https://github.com/material-components/material-components-flutter-codelabs.git cd material-components-flutter-codelabs/mdc_100_series git checkout 104-starter_and_103-complete
เปิดโปรเจ็กต์และเรียกใช้แอป
- เปิดโปรเจ็กต์ในเครื่องมือแก้ไขที่ต้องการ
- ทำตามวิธีการ "เรียกใช้แอป" ในเริ่มต้นใช้งาน: ทดลองใช้กับเครื่องมือแก้ไขที่คุณเลือก
สำเร็จ! คุณควรเห็นหน้าการเข้าสู่ระบบ Shrine จาก Codelab ก่อนหน้าบนอุปกรณ์ของคุณ
Android | iOS |
4. เพิ่มเมนูฉากหลัง
ฉากหลังจะปรากฏขึ้นด้านหลังเนื้อหาและคอมโพเนนต์อื่นๆ ทั้งหมด ซึ่งประกอบด้วย 2 เลเยอร์ ได้แก่ เลเยอร์หลัง (ที่แสดงการทำงานและตัวกรอง) และเลเยอร์หน้า (ที่แสดงเนื้อหา) คุณสามารถใช้ฉากหลังเพื่อแสดงข้อมูลและการดำเนินการแบบอินเทอร์แอกทีฟ เช่น การนำทางหรือตัวกรองเนื้อหา
นำแถบแอป Home ออก
วิดเจ็ต HomePage จะเป็นเนื้อหาของเลเยอร์หน้าสุดของเรา ตอนนี้มีแถบแอปอยู่ เราจะย้ายแถบแอปไปที่เลเยอร์หลังและหน้าแรกจะรวมเฉพาะ AsymmetricView เท่านั้น
ใน home.dart
ให้เปลี่ยนฟังก์ชัน build()
เพื่อแสดงผล AsymmetricView เท่านั้น:
// TODO: Return an AsymmetricView (104)
return AsymmetricView(products: ProductsRepository.loadProducts(Category.all));
เพิ่มวิดเจ็ตฉากหลัง
สร้างวิดเจ็ตชื่อฉากหลัง ซึ่งมี frontLayer
และ backLayer
backLayer
มีเมนูที่ให้คุณเลือกหมวดหมู่เพื่อกรองรายการได้ (currentCategory
) เนื่องจากเราต้องการคงการเลือกเมนูไว้ เราจะทำให้ฉากหลังเป็นวิดเจ็ตเก็บสถานะ
เพิ่มไฟล์ใหม่ใน /lib
ชื่อ backdrop.dart
:
import 'package:flutter/material.dart';
import 'model/product.dart';
// TODO: Add velocity constant (104)
class Backdrop extends StatefulWidget {
final Category currentCategory;
final Widget frontLayer;
final Widget backLayer;
final Widget frontTitle;
final Widget backTitle;
const Backdrop({
required this.currentCategory,
required this.frontLayer,
required this.backLayer,
required this.frontTitle,
required this.backTitle,
Key? key,
}) : super(key: key);
@override
_BackdropState createState() => _BackdropState();
}
// TODO: Add _FrontLayer class (104)
// TODO: Add _BackdropTitle class (104)
// TODO: Add _BackdropState class (104)
โปรดสังเกตว่าเราทำเครื่องหมายคุณสมบัติบางอย่าง required
วิธีนี้เป็นแนวทางปฏิบัติแนะนำสำหรับพร็อพเพอร์ตี้ในเครื่องมือสร้างที่ไม่มีค่าเริ่มต้น และเป็น null
ไม่ได้ ดังนั้นจึงไม่ควรลืม
ภายใต้คำจำกัดความของคลาสฉากหลัง ให้เพิ่มคลาส _BackdropState ดังนี้
// TODO: Add _BackdropState class (104)
class _BackdropState extends State<Backdrop>
with SingleTickerProviderStateMixin {
final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
// TODO: Add AnimationController widget (104)
// TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
Widget _buildStack() {
return Stack(
key: _backdropKey,
children: <Widget>[
// TODO: Wrap backLayer in an ExcludeSemantics widget (104)
widget.backLayer,
widget.frontLayer,
],
);
}
@override
Widget build(BuildContext context) {
var appBar = AppBar(
elevation: 0.0,
titleSpacing: 0.0,
// TODO: Replace leading menu icon with IconButton (104)
// TODO: Remove leading property (104)
// TODO: Create title with _BackdropTitle parameter (104)
leading: Icon(Icons.menu),
title: Text('SHRINE'),
actions: <Widget>[
// TODO: Add shortcut to login screen from trailing icons (104)
IconButton(
icon: Icon(
Icons.search,
semanticLabel: 'search',
),
onPressed: () {
// TODO: Add open login (104)
},
),
IconButton(
icon: Icon(
Icons.tune,
semanticLabel: 'filter',
),
onPressed: () {
// TODO: Add open login (104)
},
),
],
);
return Scaffold(
appBar: appBar,
// TODO: Return a LayoutBuilder widget (104)
body: _buildStack(),
);
}
}
ฟังก์ชัน build()
จะแสดงผล Scaffold ที่มีแถบแอปเหมือนกับที่หน้าแรกเคยเห็น แต่ตัวถังของนั่งร้านเป็นกอง องค์ประกอบย่อยของสแต็กจะทับซ้อนกันได้ ขนาดและตำแหน่งของแต่ละลูกจะถูกระบุโดยสัมพันธ์กับรายการหลักของกลุ่ม
ตอนนี้ให้เพิ่มอินสแตนซ์ฉากหลังไปยัง ShrineApp
ใน app.dart
ให้นำเข้า backdrop.dart
และ model/product.dart
:
import 'backdrop.dart'; // New code
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart'; // New code
import 'supplemental/cut_corners_border.dart';
ใน app.dart,
ให้แก้ไขเส้นทาง /
โดยแสดงผล Backdrop
ที่มี HomePage
เป็น frontLayer
:
// TODO: Change to a Backdrop with a HomePage frontLayer (104)
'/': (BuildContext context) => Backdrop(
// TODO: Make currentCategory field take _currentCategory (104)
currentCategory: Category.all,
// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(),
// TODO: Change backLayer field value to CategoryMenuPage (104)
backLayer: Container(color: kShrinePink100),
frontTitle: Text('SHRINE'),
backTitle: Text('MENU'),
),
บันทึกโปรเจ็กต์ของคุณ คุณจะเห็นว่าหน้าแรกปรากฏขึ้น เช่นเดียวกับแถบแอป
Android | iOS |
BackLayer แสดงพื้นที่สีชมพูในเลเยอร์ใหม่ด้านหลังหน้าแรกของ FrontLayer
คุณสามารถใช้ Flutter Inspector เพื่อยืนยันว่าสแต็กมีคอนเทนเนอร์อยู่หลังหน้าแรกจริงๆ ซึ่งควรมีลักษณะดังนี้
ตอนนี้คุณปรับทั้ง 2 เลเยอร์ได้แล้ว ด้านการออกแบบและเนื้อหา
5. เพิ่มรูปร่าง
ในขั้นตอนนี้ คุณจะต้องจัดรูปแบบเลเยอร์หน้าเพื่อเพิ่มการตัดที่มุมซ้ายบน
ดีไซน์ Material หมายถึงการปรับแต่งประเภทนี้ในรูปแบบรูปร่าง พื้นผิววัสดุมีรูปร่างที่กำหนดเองได้ รูปทรงช่วยเพิ่มความโดดเด่นและสไตล์ให้กับพื้นผิวต่างๆ และใช้เพื่อแสดงการสร้างแบรนด์ได้ รูปสี่เหลี่ยมผืนผ้าธรรมดาสามารถปรับแต่งด้วยมุมและขอบแบบโค้งหรือมุม และจำนวนด้านเท่าใดก็ได้ อาจเป็นแบบสมมาตรหรือไม่สม่ำเสมอ
เพิ่มรูปร่างให้กับเลเยอร์หน้า
โลโก้ Shrine แบบเอียงเป็นแรงบันดาลใจในการสร้างเรื่องราวรูปร่างของแอป Shrine เรื่องราวของรูปร่างเป็นการนำรูปทรงต่างๆ ที่พบเห็นได้ทั่วไปไปใช้กับแอปทั้งหมด ตัวอย่างเช่น รูปร่างโลโก้จะสะท้อนอยู่ในองค์ประกอบของหน้าเข้าสู่ระบบที่มีการใช้รูปร่าง ในขั้นตอนนี้ คุณจะต้องจัดรูปแบบเลเยอร์หน้าด้วยการตัดมุมที่มุมบนซ้าย
ใน backdrop.dart
ให้เพิ่มคลาสใหม่ _FrontLayer ดังนี้
// TODO: Add _FrontLayer class (104)
class _FrontLayer extends StatelessWidget {
// TODO: Add on-tap callback (104)
const _FrontLayer({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return Material(
elevation: 16.0,
shape: const BeveledRectangleBorder(
borderRadius: BorderRadius.only(topLeft: Radius.circular(46.0)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// TODO: Add a GestureDetector (104)
Expanded(
child: child,
),
],
),
);
}
}
จากนั้น ในฟังก์ชัน _buildStack()
ของ _BackdropState ให้รวมเลเยอร์หน้าใน _FrontLayer ดังนี้
Widget _buildStack() {
// TODO: Create a RelativeRectTween Animation (104)
return Stack(
key: _backdropKey,
children: <Widget>[
// TODO: Wrap backLayer in an ExcludeSemantics widget (104)
widget.backLayer,
// TODO: Add a PositionedTransition (104)
// TODO: Wrap front layer in _FrontLayer (104)
_FrontLayer(child: widget.frontLayer),
],
);
}
โหลดซ้ำ
Android | iOS |
โดยได้เปลี่ยนพื้นผิวหลักของศาลเจ้าเป็นรูปร่างที่กำหนดเอง อย่างไรก็ตาม เราต้องการให้ไอคอนนี้เชื่อมต่อภาพกับแถบแอป
เปลี่ยนสีแถบแอป
ใน app.dart
ให้เปลี่ยนฟังก์ชัน _buildShrineTheme()
เป็นดังนี้
ThemeData _buildShrineTheme() {
final ThemeData base = ThemeData.light(useMaterial3: true);
return base.copyWith(
colorScheme: base.colorScheme.copyWith(
primary: kShrinePink100,
onPrimary: kShrineBrown900,
secondary: kShrineBrown900,
error: kShrineErrorRed,
),
textTheme: _buildShrineTextTheme(base.textTheme),
textSelectionTheme: const TextSelectionThemeData(
selectionColor: kShrinePink100,
),
appBarTheme: const AppBarTheme(
foregroundColor: kShrineBrown900,
backgroundColor: kShrinePink100,
),
inputDecorationTheme: const InputDecorationTheme(
border: CutCornersBorder(),
focusedBorder: CutCornersBorder(
borderSide: BorderSide(
width: 2.0,
color: kShrineBrown900,
),
),
floatingLabelStyle: TextStyle(
color: kShrineBrown900,
),
),
);
}
ฮอตรีสตาร์ท แถบแอปสีใหม่จะปรากฏขึ้น
Android | iOS |
การเปลี่ยนแปลงนี้ทำให้ผู้ใช้เห็นว่ามีบางอย่างอยู่ด้านหลังเลเยอร์สีขาวด้านหน้า ลองเพิ่มการเคลื่อนไหวเพื่อให้ผู้ใช้มองเห็นเลเยอร์ด้านหลังของฉากหลัง
6. เพิ่มการเคลื่อนไหว
การเคลื่อนไหวทำให้แอปของคุณมีชีวิตชีวา อาจจะยิ่งใหญ่และดราม่า บอบบางและเล็กน้อยมาก หรืออยู่ตรงกลางก็ได้ แต่โปรดทราบว่าประเภทการเคลื่อนไหวที่คุณใช้ควรเหมาะสมกับสถานการณ์ การเคลื่อนไหวที่ใช้กับการกระทำซ้ำๆ เป็นประจำควรมีขนาดเล็กและบอบช้ำทางจิตใจ เพื่อไม่ให้การกระทำดังกล่าวเบี่ยงเบนความสนใจของผู้ใช้หรือใช้เวลานานเกินไปเป็นประจำ แต่ก็มีบางสถานการณ์ที่อาจดูสะดุดตามากขึ้น เช่น ครั้งแรกที่ผู้ใช้เปิดแอป และภาพเคลื่อนไหวบางอย่างก็ช่วยให้ความรู้แก่ผู้ใช้เกี่ยวกับวิธีใช้แอปได้
เพิ่มการเคลื่อนไหว "แสดง" ลงในปุ่มเมนู
ที่ด้านบนของ backdrop.dart
นอกเหนือจากขอบเขตของคลาสหรือฟังก์ชัน ให้เพิ่มค่าคงที่เพื่อแสดงความเร็วที่ต้องการให้ภาพเคลื่อนไหวมี
// TODO: Add velocity constant (104)
const double _kFlingVelocity = 2.0;
เพิ่มวิดเจ็ต AnimationController ลงใน _BackdropState ทำอินสแตนซ์ในฟังก์ชัน initState()
แล้วกำจัดทิ้งในฟังก์ชัน dispose()
ของสถานะ ดังนี้
// TODO: Add AnimationController widget (104)
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
value: 1.0,
vsync: this,
);
}
// TODO: Add override for didUpdateWidget (104)
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// TODO: Add functions to get and change front layer visibility (104)
AnimationController ประสานงานภาพเคลื่อนไหว และให้ API แก่คุณเพื่อเล่น ย้อนกลับ และหยุดภาพเคลื่อนไหว ตอนนี้เราต้องมีฟังก์ชันที่เคลื่อนไหวได้
เพิ่มฟังก์ชันที่กำหนดและเปลี่ยนการเปิดเผยของเลเยอร์หน้า:
// TODO: Add functions to get and change front layer visibility (104)
bool get _frontLayerVisible {
final AnimationStatus status = _controller.status;
return status == AnimationStatus.completed ||
status == AnimationStatus.forward;
}
void _toggleBackdropLayerVisibility() {
_controller.fling(
velocity: _frontLayerVisible ? -_kFlingVelocity : _kFlingVelocity);
}
รวม BackLayer ในวิดเจ็ต ExcludeSemantics วิดเจ็ตนี้จะยกเว้นรายการเมนูของ BackLayer จากแผนผังอรรถศาสตร์เมื่อมองไม่เห็นเลเยอร์หลัง
return Stack(
key: _backdropKey,
children: <Widget>[
// TODO: Wrap backLayer in an ExcludeSemantics widget (104)
ExcludeSemantics(
child: widget.backLayer,
excluding: _frontLayerVisible,
),
...
เปลี่ยนฟังก์ชัน _buildStack() เพื่อใช้ BuildContext และ BoxConstraints นอกจากนี้ยังรวมการเปลี่ยนตำแหน่งที่ใช้ภาพเคลื่อนไหวแบบ RelativeRectTween ด้วย ดังนี้
// TODO: Add BuildContext and BoxConstraints parameters to _buildStack (104)
Widget _buildStack(BuildContext context, BoxConstraints constraints) {
const double layerTitleHeight = 48.0;
final Size layerSize = constraints.biggest;
final double layerTop = layerSize.height - layerTitleHeight;
// TODO: Create a RelativeRectTween Animation (104)
Animation<RelativeRect> layerAnimation = RelativeRectTween(
begin: RelativeRect.fromLTRB(
0.0, layerTop, 0.0, layerTop - layerSize.height),
end: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
).animate(_controller.view);
return Stack(
key: _backdropKey,
children: <Widget>[
// TODO: Wrap backLayer in an ExcludeSemantics widget (104)
ExcludeSemantics(
child: widget.backLayer,
excluding: _frontLayerVisible,
),
// TODO: Add a PositionedTransition (104)
PositionedTransition(
rect: layerAnimation,
child: _FrontLayer(
// TODO: Implement onTap property on _BackdropState (104)
child: widget.frontLayer,
),
),
],
);
}
สุดท้าย แทนที่จะเรียกใช้ฟังก์ชัน _buildStack สำหรับเนื้อหาของ Scaffold ให้แสดงผลวิดเจ็ต LayoutBuilder ที่ใช้ _buildStack เป็นเครื่องมือสร้าง:
return Scaffold(
appBar: appBar,
// TODO: Return a LayoutBuilder widget (104)
body: LayoutBuilder(builder: _buildStack),
);
เราได้เลื่อนการสร้างสแต็กเลเยอร์ด้านหน้า/ด้านหลังจนถึงเวลาของเลย์เอาต์โดยใช้ LayoutBuilder เพื่อให้เรารวมความสูงโดยรวมตามจริงของฉากหลังได้ LayoutBuilder เป็นวิดเจ็ตพิเศษที่ Callback ของเครื่องมือสร้างมีข้อจำกัดด้านขนาด
ในฟังก์ชัน build()
ให้เปลี่ยนไอคอนเมนูนำหน้าในแถบแอปเป็น "ปุ่มไอคอน" และใช้เพื่อสลับการมองเห็นเลเยอร์หน้าเมื่อแตะปุ่ม
// TODO: Replace leading menu icon with IconButton (104)
leading: IconButton(
icon: const Icon(Icons.menu),
onPressed: _toggleBackdropLayerVisibility,
),
โหลดซ้ำแล้วแตะปุ่มเมนูในเครื่องมือจำลอง
Android | iOS |
เลเยอร์ด้านหน้าเคลื่อนไหว (เลื่อน) ลง แต่หากมองลงไปจะเห็นข้อผิดพลาดสีแดงและข้อผิดพลาดเพิ่มเติมแสดง เนื่องจาก AsymmetricView ถูกบีบและมีขนาดเล็กลงจากภาพเคลื่อนไหวนี้ ทำให้คอลัมน์ต่างๆ มีพื้นที่น้อยลง ซึ่งท้ายที่สุดแล้ว คอลัมน์จะแสดงพื้นที่ที่กำหนดไว้ไม่ได้และส่งผลให้เกิดข้อผิดพลาด หากเราแทนที่คอลัมน์ด้วย ListView ขนาดคอลัมน์ควรจะยังคงเดิมเมื่อมีการเคลื่อนไหว
ตัดคอลัมน์ผลิตภัณฑ์ใน ListView
ใน supplemental/product_columns.dart
ให้แทนที่คอลัมน์ใน OneProductCardColumn
ด้วย ListView
class OneProductCardColumn extends StatelessWidget {
const OneProductCardColumn({required this.product, Key? key}) : super(key: key);
final Product product;
@override
Widget build(BuildContext context) {
// TODO: Replace Column with a ListView (104)
return ListView(
physics: const ClampingScrollPhysics(),
reverse: true,
children: <Widget>[
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 550,
),
child: ProductCard(
product: product,
),
),
const SizedBox(
height: 40.0,
),
],
);
}
}
คอลัมน์ดังกล่าวมี MainAxisAlignment.end
เริ่มเลย์เอาต์จากด้านล่าง ทําเครื่องหมาย reverse: true
คำสั่งซื้อรายการย่อยจะถูกกลับรายการเพื่อชดเชยการเปลี่ยนแปลง
โหลดซ้ำแล้วแตะปุ่มเมนู
Android | iOS |
คำเตือนรายการเพิ่มเติมสีเทาใน OneProductCardColumn หายไปแล้ว ตอนนี้ลองแก้ไขอีกข้อหนึ่ง
ใน supplemental/product_columns.dart
ให้เปลี่ยนวิธีคำนวณ imageAspectRatio
และแทนที่คอลัมน์ใน TwoProductCardColumn
ด้วย ListView
// TODO: Change imageAspectRatio calculation (104)
double imageAspectRatio = heightOfImages >= 0.0
? constraints.biggest.width / heightOfImages
: 49.0 / 33.0;
// TODO: Replace Column with a ListView (104)
return ListView(
physics: const ClampingScrollPhysics(),
children: <Widget>[
Padding(
padding: const EdgeInsetsDirectional.only(start: 28.0),
child: top != null
? ProductCard(
imageAspectRatio: imageAspectRatio,
product: top!,
)
: SizedBox(
height: heightOfCards,
),
),
const SizedBox(height: spacerHeight),
Padding(
padding: const EdgeInsetsDirectional.only(end: 28.0),
child: ProductCard(
imageAspectRatio: imageAspectRatio,
product: bottom,
),
),
],
);
นอกจากนี้ เรายังเพิ่มความปลอดภัยให้กับ imageAspectRatio
ด้วย
โหลดซ้ำ แล้วแตะปุ่มเมนู
Android | iOS |
ไม่มีส่วนเกินอีกต่อไป
7. เพิ่มเมนูในเลเยอร์ด้านหลัง
เมนูเป็นรายการข้อความแบบแตะได้ซึ่งจะแจ้งให้ผู้ฟังทราบเมื่อมีการแตะรายการข้อความ ในขั้นตอนนี้ คุณจะต้องเพิ่มเมนูการกรองหมวดหมู่
เพิ่มเมนู
เพิ่มเมนูในเลเยอร์หน้าและปุ่มแบบอินเทอร์แอกทีฟที่เลเยอร์หลัง
สร้างไฟล์ใหม่ชื่อ lib/category_menu_page.dart
import 'package:flutter/material.dart';
import 'colors.dart';
import 'model/product.dart';
class CategoryMenuPage extends StatelessWidget {
final Category currentCategory;
final ValueChanged<Category> onCategoryTap;
final List<Category> _categories = Category.values;
const CategoryMenuPage({
Key? key,
required this.currentCategory,
required this.onCategoryTap,
}) : super(key: key);
Widget _buildCategory(Category category, BuildContext context) {
final categoryString =
category.toString().replaceAll('Category.', '').toUpperCase();
final ThemeData theme = Theme.of(context);
return GestureDetector(
onTap: () => onCategoryTap(category),
child: category == currentCategory
? Column(
children: <Widget>[
const SizedBox(height: 16.0),
Text(
categoryString,
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 14.0),
Container(
width: 70.0,
height: 2.0,
color: kShrinePink400,
),
],
)
: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Text(
categoryString,
style: theme.textTheme.bodyLarge!.copyWith(
color: kShrineBrown900.withAlpha(153)
),
textAlign: TextAlign.center,
),
),
);
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: const EdgeInsets.only(top: 40.0),
color: kShrinePink100,
child: ListView(
children: _categories
.map((Category c) => _buildCategory(c, context))
.toList()),
),
);
}
}
เป็น GestureDetector ที่รวมคอลัมน์ที่มีชื่อหมวดหมู่ย่อย เส้นใต้จะใช้เพื่อระบุหมวดหมู่ที่เลือก
ใน app.dart
ให้แปลงวิดเจ็ต ShrineApp จากไม่เก็บสถานะเป็นเก็บสถานะ
- ไฮไลต์
ShrineApp.
- แสดงการทำงานของโค้ดตาม IDE ของคุณ
- Android Studio: กด ⌥Enter (macOS) หรือ alt + Enter
- VS โค้ด: กด ⌘ (macOS) หรือ Ctrl+
- เลือก "แปลงเป็น StatefulWidget"
- เปลี่ยนคลาส ShrineAppState เป็นแบบส่วนตัว (_ShrineAppState) คลิกขวาที่ ShrineAppState และ
- Android Studio: เลือกเปลี่ยนโครงสร้างภายในโค้ด > เปลี่ยนชื่อ
- VS Code: เลือก "เปลี่ยนชื่อสัญลักษณ์"
- ป้อน _ShrineAppState เพื่อทำให้ชั้นเรียนเป็นส่วนตัว
ใน app.dart
ให้เพิ่มตัวแปรไปยัง _ShrineAppState สำหรับหมวดหมู่ที่เลือกและ Callback เมื่อมีการแตะ
class _ShrineAppState extends State<ShrineApp> {
Category _currentCategory = Category.all;
void _onCategoryTap(Category category) {
setState(() {
_currentCategory = category;
});
}
จากนั้นเปลี่ยนเลเยอร์กลับเป็น CategoryMenuPage
ใน app.dart
ให้นำเข้า CategoryMenuPage:
import 'backdrop.dart';
import 'category_menu_page.dart';
import 'colors.dart';
import 'home.dart';
import 'login.dart';
import 'model/product.dart';
import 'supplemental/cut_corners_border.dart';
ในฟังก์ชัน build()
ให้เปลี่ยนช่อง backLayer เป็น CategoryMenuPage และช่อง CurrentCategory เพื่อรับตัวแปรอินสแตนซ์
'/': (BuildContext context) => Backdrop(
// TODO: Make currentCategory field take _currentCategory (104)
currentCategory: _currentCategory,
// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(),
// TODO: Change backLayer field value to CategoryMenuPage (104)
backLayer: CategoryMenuPage(
currentCategory: _currentCategory,
onCategoryTap: _onCategoryTap,
),
frontTitle: const Text('SHRINE'),
backTitle: const Text('MENU'),
),
โหลดซ้ำแล้วแตะปุ่มเมนู
Android | iOS |
หากคุณแตะตัวเลือกเมนู ก็ยังไม่มีอะไรเกิดขึ้น เรามาแก้ปัญหานั้นกันดีกว่า
ใน home.dart
ให้เพิ่มตัวแปรสำหรับหมวดหมู่แล้วส่งไปยัง AsymmetricView
import 'package:flutter/material.dart';
import 'model/product.dart';
import 'model/products_repository.dart';
import 'supplemental/asymmetric_view.dart';
class HomePage extends StatelessWidget {
// TODO: Add a variable for Category (104)
final Category category;
const HomePage({this.category = Category.all, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// TODO: Pass Category variable to AsymmetricView (104)
return AsymmetricView(
products: ProductsRepository.loadProducts(category),
);
}
}
ในapp.dart
ให้สอบ_currentCategory
ได้ frontLayer
:
// TODO: Pass _currentCategory for frontLayer (104)
frontLayer: HomePage(category: _currentCategory),
โหลดซ้ำ แตะปุ่มเมนูในเครื่องมือจำลอง และเลือกหมวดหมู่
Android | iOS |
กรองแล้ว
ปิด เลเยอร์หน้าหลังจากเมนูที่เลือก
ใน backdrop.dart
ให้เพิ่มการลบล้างสำหรับฟังก์ชัน didUpdateWidget()
(เรียกว่าเมื่อใดก็ตามที่มีการเปลี่ยนแปลงการกำหนดค่าวิดเจ็ต) ใน _BackdropState:
// TODO: Add override for didUpdateWidget() (104)
@override
void didUpdateWidget(Backdrop old) {
super.didUpdateWidget(old);
if (widget.currentCategory != old.currentCategory) {
_toggleBackdropLayerVisibility();
} else if (!_frontLayerVisible) {
_controller.fling(velocity: _kFlingVelocity);
}
}
บันทึกโปรเจ็กต์เพื่อทริกเกอร์การโหลดซ้ำแบบ Hot แตะไอคอนเมนูและเลือกหมวดหมู่ เมนูควรปิดโดยอัตโนมัติและคุณจะเห็นหมวดหมู่ของรายการที่เลือก ถึงตอนนี้คุณจะต้องเพิ่มฟังก์ชันดังกล่าวไว้ในเลเยอร์หน้าด้วย
สลับเลเยอร์หน้า
ใน backdrop.dart
ให้เพิ่ม Callback เมื่อมีการแตะลงในเลเยอร์ฉากหลังโดยทำดังนี้
class _FrontLayer extends StatelessWidget {
// TODO: Add on-tap callback (104)
const _FrontLayer({
Key? key,
this.onTap, // New code
required this.child,
}) : super(key: key);
final VoidCallback? onTap; // New code
final Widget child;
จากนั้นเพิ่ม GestureDetector ลงในองค์ประกอบย่อยของ _FrontLayer: องค์ประกอบย่อยของคอลัมน์:
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
// TODO: Add a GestureDetector (104)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 40.0,
alignment: AlignmentDirectional.centerStart,
),
),
Expanded(
child: child,
),
],
),
จากนั้นติดตั้งใช้งานพร็อพเพอร์ตี้ onTap
ใหม่บน _BackdropState ในฟังก์ชัน _buildStack()
ดังนี้
PositionedTransition(
rect: layerAnimation,
child: _FrontLayer(
// TODO: Implement onTap property on _BackdropState (104)
onTap: _toggleBackdropLayerVisibility,
child: widget.frontLayer,
),
),
โหลดซ้ำและแตะด้านบนของเลเยอร์ด้านหน้า เลเยอร์ควรเปิดและปิดทุกครั้งที่คุณแตะด้านบนของเลเยอร์ด้านหน้า
8. เพิ่มไอคอนแบรนด์
ระบบการตีความสัญลักษณ์ของแบรนด์ยังครอบคลุมถึงไอคอนที่คุ้นเคยด้วยเช่นกัน เราจะทำให้ไอคอนแสดงขึ้นมาเป็นแบบกำหนดเองและรวมเข้ากับชื่อของเราเพื่อให้ภาพลักษณ์ของแบรนด์ไม่ซ้ำใคร
เปลี่ยนไอคอนปุ่มเมนู
Android | iOS |
ใน backdrop.dart
ให้สร้างชั้นเรียนใหม่ _BackdropTitle
// TODO: Add _BackdropTitle class (104)
class _BackdropTitle extends AnimatedWidget {
final void Function() onPress;
final Widget frontTitle;
final Widget backTitle;
const _BackdropTitle({
Key? key,
required Animation<double> listenable,
required this.onPress,
required this.frontTitle,
required this.backTitle,
}) : _listenable = listenable,
super(key: key, listenable: listenable);
final Animation<double> _listenable;
@override
Widget build(BuildContext context) {
final Animation<double> animation = _listenable;
return DefaultTextStyle(
style: Theme.of(context).textTheme.titleLarge!,
softWrap: false,
overflow: TextOverflow.ellipsis,
child: Row(children: <Widget>[
// branded icon
SizedBox(
width: 72.0,
child: IconButton(
padding: const EdgeInsets.only(right: 8.0),
onPressed: this.onPress,
icon: Stack(children: <Widget>[
Opacity(
opacity: animation.value,
child: const ImageIcon(AssetImage('assets/slanted_menu.png')),
),
FractionalTranslation(
translation: Tween<Offset>(
begin: Offset.zero,
end: const Offset(1.0, 0.0),
).evaluate(animation),
child: const ImageIcon(AssetImage('assets/diamond.png')),
)]),
),
),
// Here, we do a custom cross fade between backTitle and frontTitle.
// This makes a smooth animation between the two texts.
Stack(
children: <Widget>[
Opacity(
opacity: CurvedAnimation(
parent: ReverseAnimation(animation),
curve: const Interval(0.5, 1.0),
).value,
child: FractionalTranslation(
translation: Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, 0.0),
).evaluate(animation),
child: backTitle,
),
),
Opacity(
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0.5, 1.0),
).value,
child: FractionalTranslation(
translation: Tween<Offset>(
begin: const Offset(-0.25, 0.0),
end: Offset.zero,
).evaluate(animation),
child: frontTitle,
),
),
],
)
]),
);
}
}
_BackdropTitle
เป็นวิดเจ็ตที่กำหนดเองซึ่งจะแทนที่วิดเจ็ต Text
แบบธรรมดาสำหรับพารามิเตอร์ title
ของวิดเจ็ต AppBar
โฆษณาจะมีไอคอนเมนูแบบเคลื่อนไหวและการเปลี่ยนแบบภาพเคลื่อนไหวระหว่างชื่อหน้ากับชื่อหลัง ไอคอนเมนูแบบเคลื่อนไหวจะใช้เนื้อหาใหม่ คุณต้องเพิ่มการอ้างอิง slanted_menu.png
ใหม่ลงใน pubspec.yaml
assets:
- assets/diamond.png
# TODO: Add slanted menu asset (104)
- assets/slanted_menu.png
- packages/shrine_images/0-0.jpg
นำพร็อพเพอร์ตี้ leading
ในเครื่องมือสร้าง AppBar
ออก ต้องนำออกเพื่อให้ไอคอนแบรนด์ที่กำหนดเองแสดงผลในตำแหน่งของวิดเจ็ต leading
เดิม ระบบจะส่งภาพเคลื่อนไหว listenable
และเครื่องจัดการ onPress
สำหรับไอคอนแบรนด์ไปยัง _BackdropTitle
ระบบส่งผ่าน frontTitle
และ backTitle
ด้วยเพื่อให้แสดงผลภายในชื่อฉากหลังได้ พารามิเตอร์ title
ของ AppBar
ควรมีลักษณะดังนี้
// TODO: Create title with _BackdropTitle parameter (104)
title: _BackdropTitle(
listenable: _controller.view,
onPress: _toggleBackdropLayerVisibility,
frontTitle: widget.frontTitle,
backTitle: widget.backTitle,
),
ไอคอนแบรนด์จะสร้างขึ้นใน _BackdropTitle.
ซึ่งจะมี Stack
ไอคอนแบบเคลื่อนไหว ได้แก่ เมนูเอียงและเพชร ซึ่งห่อด้วยตัวอักษร IconButton
เพื่อให้กดไอคอนได้ จากนั้น IconButton
จะรวมไว้ใน SizedBox
เพื่อให้มีพื้นที่สำหรับการเคลื่อนไหวไอคอนแนวนอน
"ทุกอย่างที่เป็นวิดเจ็ต" ของ Flutter สถาปัตยกรรมช่วยให้ปรับเปลี่ยนเลย์เอาต์ของ AppBar
เริ่มต้นได้โดยไม่ต้องสร้างวิดเจ็ต AppBar
แบบกำหนดเองใหม่ทั้งหมด พารามิเตอร์ title
ซึ่งเดิมเป็นวิดเจ็ต Text
สามารถแทนที่ด้วย _BackdropTitle
ที่ซับซ้อนขึ้นได้ เนื่องจาก _BackdropTitle
มีไอคอนที่กำหนดเองด้วย จึงมาแทนที่พร็อพเพอร์ตี้ leading
ซึ่งตอนนี้ละเว้นได้ การแทนที่วิดเจ็ตง่ายๆ นี้สามารถทำได้โดยไม่ต้องเปลี่ยนแปลงพารามิเตอร์อื่นๆ เช่น ไอคอนการทำงาน ซึ่งยังคงทำงานได้ด้วยตัวเองต่อไป
เพิ่มทางลัดกลับไปที่หน้าจอการเข้าสู่ระบบ
ในbackdrop.dart,
ให้เพิ่มทางลัดกลับไปที่หน้าจอการเข้าสู่ระบบจากไอคอน 2 ไอคอนต่อท้ายในแถบแอป โดยเปลี่ยนป้ายกำกับเชิงความหมายของไอคอนเพื่อให้สอดคล้องกับวัตถุประสงค์ใหม่
// TODO: Add shortcut to login screen from trailing icons (104)
IconButton(
icon: const Icon(
Icons.search,
semanticLabel: 'login', // New code
),
onPressed: () {
// TODO: Add open login (104)
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => LoginPage()),
);
},
),
IconButton(
icon: const Icon(
Icons.tune,
semanticLabel: 'login', // New code
),
onPressed: () {
// TODO: Add open login (104)
Navigator.push(
context,
MaterialPageRoute(
builder: (BuildContext context) => LoginPage()),
);
},
),
คุณจะได้รับข้อผิดพลาดหากลองโหลดซ้ำ โปรดนำเข้า login.dart
เพื่อแก้ไขข้อผิดพลาด
import 'login.dart';
โหลดแอปซ้ำแล้วแตะปุ่มค้นหาหรือปรับแต่งเพื่อกลับไปยังหน้าจอการเข้าสู่ระบบ
9. ยินดีด้วย
ในบทเรียนจาก Codelab ทั้ง 4 ครั้งนี้ คุณได้เรียนรู้วิธีใช้คอมโพเนนต์ของ Material เพื่อสร้างประสบการณ์การใช้งานที่สง่างามไม่เหมือนใคร ซึ่งสะท้อนถึงบุคลิกภาพและสไตล์ของแบรนด์
ขั้นตอนถัดไป
Codelab ที่ชื่อว่า MDC-104 นี้ทำให้ลำดับ Codelab นี้เสร็จสมบูรณ์ คุณสำรวจคอมโพเนนต์เพิ่มเติมใน Material Flutter ได้โดยไปที่แคตตาล็อกวิดเจ็ต Material Components
สำหรับเป้าหมายแบบขยาย ให้ลองแทนที่ไอคอนที่มีแบรนด์ด้วย AnimatedIcon ที่เคลื่อนไหวระหว่าง 2 ไอคอนเมื่อทำให้ฉากหลังปรากฏขึ้น
มี Codelab ของ Flutter มากมายให้ลองใช้โดยอิงตามความสนใจของคุณ เรามี Codelab เฉพาะเกี่ยวกับ Material อีกรายการหนึ่งที่คุณอาจสนใจ ซึ่งได้แก่ การสร้างการเปลี่ยนภาพที่สวยงามด้วยการเคลื่อนไหวของ Material สำหรับ Flutter