Material Design:Angular & Flutter (一)

img

前言

Material Design 是由 Google 所推廣的設計風格,當然 Google 自家的產品也遵循這個風格來設計,而對於它所釋出的開源框架 AngularFlutter 也分別提供 Angular MaterialMaterial Components widgets 來協助我們很快速地就可以設計具有 Material Design 的程式,今天我們就來嘗試 Angular 與 Flutter 如何實作。

開發環境

Angular

Angular 開發環境主要需要 Node.jsAngular CLI 最後就是開發工具,筆者是用 Visual Studio Code (簡稱 VS Code)。
安裝完 Node.js 之後我們可以透過指令 npm install -g @angular/cli 來安裝 Angular CLI,接著就是透過 CLI 來建立專案,筆者建立一個名為 material-angular 的專案,並同時加入路由功能(routing),網頁樣式檔採用 SCSS 格式,完整指令如下:
ng new material-angular --routing --style scss
img
接著透過指令 npm start (或是 ng serve) 執行專案。
img
最後透過瀏覽器開啟 http://localhost:4200/,便可看到專案預設網頁。
img

Flutter

Flutter 開發環境主要需要 Flutter SDK,開發 APP 則需要 Android Studio(Android SDK、模擬器)、Xcode(iOS SDK、模擬器),開發 Web 則需安裝 webdev。

詳細安裝方式請參考官方文件,或是 用 VS Code 建置 Flutter 開發環境Dart 敗部復活賽:Flutter for Web

接著我們一樣採用 VS Code 來開發,這邊為了方便比較我們使用 Flutter for Web 來開發,我們透過 VS Code 上 Dart & Flutter 的擴充功能來建立一個名為 material_flutter 的專案。
img

Dart & Flutter 的擴充功能 需要 3.0.0 以上版本。
Angular 專案名稱不允許使用**底線(_),Flutter 專案名稱不允許使用連字符號(-)**。

再來透過指令 webdev serve 來啟動專案。
img
最後透過瀏覽器開啟 http://localhost:8080,便可看到專案預設網頁。
img

Material

Angular

Angular 專案要加入 Angular Material 可以參考官方文件 Getting started,比較快的方式是透過 Schematics 指令 ng add @angular/material 來加入,包含網頁模板、動畫、手勢都可以透過設定選項來幫我們一次處理好。
img
Angular Material 的 Component 都會包覆成獨立的NgModule,我們再依需求個別加入到專案內,比較方便的方式就是建立一個 SharedModule 統一加入到此,也方便後續維護管理,我們建立一個 src\app\shared-material-module.ts,並先加入所有 Angular Material 的 Component,等到專案建置完之後再移除掉不需要的 Component,程式碼如下:

material-module.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import { A11yModule } from '@angular/cdk/a11y';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { PortalModule } from '@angular/cdk/portal';

import { ScrollingModule } from '@angular/cdk/scrolling';
import { CdkStepperModule } from '@angular/cdk/stepper';
import { CdkTableModule } from '@angular/cdk/table';
import { CdkTreeModule } from '@angular/cdk/tree';
import { NgModule } from '@angular/core';

import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatBadgeModule } from '@angular/material/badge';
import { MatBottomSheetModule } from '@angular/material/bottom-sheet';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatNativeDateModule, MatRippleModule } from '@angular/material/core';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatGridListModule } from '@angular/material/grid-list';
import { MatIconModule } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatListModule } from '@angular/material/list';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatProgressBarModule } from '@angular/material/progress-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatRadioModule } from '@angular/material/radio';
import { MatSelectModule } from '@angular/material/select';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { MatSliderModule } from '@angular/material/slider';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSortModule } from '@angular/material/sort';
import { MatStepperModule } from '@angular/material/stepper';
import { MatTableModule } from '@angular/material/table';
import { MatTabsModule } from '@angular/material/tabs';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatTreeModule } from '@angular/material/tree';

@NgModule({
imports: [
A11yModule, CdkTableModule, CdkTreeModule, CdkStepperModule,
DragDropModule, MatAutocompleteModule, MatBadgeModule, MatBottomSheetModule,
MatButtonModule, MatButtonToggleModule, MatCardModule, MatCheckboxModule,
MatChipsModule, MatDatepickerModule, MatDialogModule, MatDividerModule,
MatExpansionModule, MatFormFieldModule, MatGridListModule, MatIconModule,
MatInputModule, MatListModule, MatMenuModule, MatPaginatorModule,
MatProgressBarModule, MatProgressSpinnerModule, MatRadioModule, MatRippleModule,
MatSelectModule, MatSidenavModule, MatSlideToggleModule, MatSliderModule,
MatSnackBarModule, MatSortModule, MatStepperModule, MatTableModule,
MatTabsModule, MatToolbarModule, MatTooltipModule, MatTreeModule,
ScrollingModule, PortalModule, MatNativeDateModule,
],
exports: [
A11yModule, CdkTableModule, CdkTreeModule, CdkStepperModule,
DragDropModule, MatAutocompleteModule, MatBadgeModule, MatBottomSheetModule,
MatButtonModule, MatButtonToggleModule, MatCardModule, MatCheckboxModule,
MatChipsModule, MatDatepickerModule, MatDialogModule, MatDividerModule,
MatExpansionModule, MatFormFieldModule, MatGridListModule, MatIconModule,
MatInputModule, MatListModule, MatMenuModule, MatPaginatorModule,
MatProgressBarModule, MatProgressSpinnerModule, MatRadioModule, MatRippleModule,
MatSelectModule, MatSidenavModule, MatSlideToggleModule, MatSliderModule,
MatSnackBarModule, MatSortModule, MatStepperModule, MatTableModule,
MatTabsModule, MatToolbarModule, MatTooltipModule, MatTreeModule,
ScrollingModule, PortalModule, MatNativeDateModule,
]
})
export class SharedMaterialModule { }

可以參考官方的範例的 material-module.ts

接著將 SharedMaterialModule 加入到 AppModule(src\app\app.module.ts),這樣 AppModule 下面的 Component 就可以使用,AppModule 的 bootstrap 預設指定 AppComponent 為起始頁面,到這裡我們就已經完成整個專案的設定。
img

Flutter

Flutter 專案預設已經透過 MaterialApp 幫我們將相關設定都完成了,theme 就跟 CSS 一樣,我們可以修改一些樣式設定,home 則提供預設要載入的 Widget,我們可以將 Flutter 的 Widget 視同 Angular 的 Component,MaterialApp 的 home 就相當於 AppModule 的 bootstrap
img
比較特別的是目前必須手動加入 Material Icons,加入方法是在專案目錄 web\assets\ 內建立一個 FontManifest.json 檔案(預設沒有 assets 資料夾,需自行建立)。
img
並加入下列 JSON 資料:

FontManifest.json
1
2
3
4
5
6
7
8
9
10
[
{
"family": "MaterialIcons",
"fonts": [
{
"asset": "https://fonts.gstatic.com/s/materialicons/v42/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2"
}
]
}
]

版型

Angular

Angular Material 提供了 toolbarsidenav 可以讓我們快速建立一個具有工作欄側邊欄的版型,修改網頁 src\app\app.component.html 如下:

app.component.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<mat-drawer-container style="height: 100vh; width:100vw;" autosize>
<mat-drawer #drawer style="width: 240px;" mode="over">
Menu
</mat-drawer>
<mat-toolbar color="primary">
<button mat-icon-button (click)="drawer.toggle()">
<mat-icon class="example-icon">menu</mat-icon>
</button>
<span>Angular Material</span>
</mat-toolbar>
<div>
Context
</div>
</mat-drawer-container>

為了與 Flutter 效果相近這裡側邊欄採用 mat-drawer

執行專案可以看到效果如下:
img

Flutter

Flutter 提供一個 Scaffold Widget,它預先配置頁面的基本布局,我們只要將對應的 Widget(AppBarDrawer) 填入就可以完成版型,我直接修改 lib\main.dart 內的 MyHomePage
img
程式碼如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyHomePage extends StatelessWidget {
final String title;

MyHomePage({Key key, this.title}) : super(key: key);

@override
Widget build(BuildContext context) {
final appBar = AppBar(
title: Text(title),
);

final drawer = Drawer(
child: Text('Menu'),
);

final body = Text('Context');

return Scaffold(
appBar: appBar,
drawer: drawer,
body: body,
);
}
}

執行專案可以看到與 Angular 相同的效果:
img

路由導覽

Angular

因為我們在建立專案時已經透過 Angular CLI 幫我們建立好路由設定,接下來只要直接使用即可,我們分別透過指令 ng g c page1ng g c page2 建立2個 Component - Page1ComponentPage2Component
img
接著修改 src\app\page1\page1.component.htmlsrc\app\page2\page2.component.html 內文字的大小與顏色,以方便識別頁面。
img
接著在 src\app\app-routing.module.ts 內添加路由規則:

  • p1 會導覽到 Page1Component。
  • p2 會導覽到 Page2Component。
  • ** 其他路徑則切換回 p1,也就是會導覽到 Page1Component。

img
最後開啟 src\app\app.component.html,先將 Context 替換為路由插座 router-outlet,當路由運作時它會將路由規則所對應的 Component 插入到這個路由插座內,接著在 mat-drawer 內加入導覽用的按鈕,我們可以直接透過 routerLink 屬性來設定要導覽的路徑。
img
重新執行專案,可以看到頁面切換的導覽效果。
img

Flutter

仿造 Angular 我們一樣建立2個 Widget-Page1Page2,一樣在側邊欄 Drawer 內加入導覽按鈕。
特別的是在導覽之前會先執行 Navigator.of(context).pop();,因為 Drawer 不是建立在目前 Widget 內,而是堆疊在目前 Widget 之上,藉由 pop 方法我們先接它移除,另一點是 Flutter 是整頁切換,所以我們必須在每個頁面都使用 Scaffold 這個 Widget 包覆內容。
img
最後我們在 MyApp 內的 MaterialApp 插入路由規則,要注意的是當 routes 內包含 \ 時,外面的 home 屬性就不能設定,反之亦然,擇一使用。
img
完整程式如下:

main.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import 'package:flutter_web/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
// home: MyHomePage(title: 'Flutter Material'),
routes: {
'/': (context) => Page1(),
'/p1': (context) => Page1(),
'/p2': (context) => Page2(),
},
);
}
}

class Page1 extends StatelessWidget {
@override
Widget build(BuildContext context) {
final drawer = Drawer(
child: ListView(
children: <Widget>[
ListTile(
title: Text('Page1'),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/p1');
},
),
ListTile(
title: Text('Page2'),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/p2');
},
),
],
),
);

final body = Center(
child: Text(
'Page1',
style: TextStyle(fontSize: 32, color: Colors.green),
),
);

return Scaffold(
appBar: AppBar(title: Text('Page1')),
drawer: drawer,
body: body,
);
}
}

class Page2 extends StatelessWidget {
@override
Widget build(BuildContext context) {
final drawer = Drawer(
child: ListView(
children: <Widget>[
ListTile(
title: Text('Page1'),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/p1');
},
),
ListTile(
title: Text('Page2'),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).pushNamed('/p2');
},
),
],
),
);

final body = Center(
child: Text(
'Page2',
style: TextStyle(fontSize: 32, color: Colors.blue),
),
);

return Scaffold(
appBar: AppBar(title: Text('Page2')),
drawer: drawer,
body: body,
);
}
}

重新執行專案,可以看到頁面切換的導覽效果。
img

後記

這邊並不是在比較 Angular 與 Flutter 哪一個比較好,使用自己最熟悉的技術就可以,剩下的 Google 相關團隊會讓它們越來越強大。