Material Design:Angular & Flutter (一)

前言

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

接著透過指令 npm start (或是 ng serve) 執行專案。

最後透過瀏覽器開啟 http://localhost:4200/,便可看到專案預設網頁。

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 的專案。

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

再來透過指令 webdev serve 來啟動專案。

最後透過瀏覽器開啟 http://localhost:8080,便可看到專案預設網頁。

Material

Angular

Angular 專案要加入 Angular Material 可以參考官方文件 Getting started,比較快的方式是透過 Schematics 指令 ng add @angular/material 來加入,包含網頁模板、動畫、手勢都可以透過設定選項來幫我們一次處理好。

Angular Material 的 Component 都會包覆成獨立的NgModule,我們再依需求個別加入到專案內,比較方便的方式就是建立一個 SharedModule 統一加入到此,也方便後續維護管理,我們建立一個 src\app\shared-material-module.ts,並先加入所有 Angular Material 的 Component,等到專案建置完之後再移除掉不需要的 Component,程式碼如下:

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 為起始頁面,到這裡我們就已經完成整個專案的設定。

Flutter

Flutter 專案預設已經透過 MaterialApp 幫我們將相關設定都完成了,theme 就跟 CSS 一樣,我們可以修改一些樣式設定,home 則提供預設要載入的 Widget,我們可以將 Flutter 的 Widget 視同 Angular 的 Component,MaterialApp 的 home 就相當於 AppModule 的 bootstrap

比較特別的是目前必須手動加入 Material Icons,加入方法是在專案目錄 web\assets\ 內建立一個 FontManifest.json 檔案(預設沒有 assets 資料夾,需自行建立)。

並加入下列 JSON 資料:

[
    {
        "family": "MaterialIcons",
        "fonts": [
            {
                "asset": "https://fonts.gstatic.com/s/materialicons/v42/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2"
            }
        ]
    }
]

版型

Angular

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

<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

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

Flutter

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

程式碼如下:

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 相同的效果:

路由導覽

Angular

因為我們在建立專案時已經透過 Angular CLI 幫我們建立好路由設定,接下來只要直接使用即可,我們分別透過指令 ng g c page1ng g c page2 建立2個 Component - Page1ComponentPage2Component

接著修改 src\app\page1\page1.component.htmlsrc\app\page2\page2.component.html 內文字的大小與顏色,以方便識別頁面。

接著在 src\app\app-routing.module.ts 內添加路由規則:

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


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

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

Flutter

仿造 Angular 我們一樣建立2個 Widget-Page1Page2,一樣在側邊欄 Drawer 內加入導覽按鈕。
特別的是在導覽之前會先執行 Navigator.of(context).pop();,因為 Drawer 不是建立在目前 Widget 內,而是堆疊在目前 Widget 之上,藉由 pop 方法我們先接它移除,另一點是 Flutter 是整頁切換,所以我們必須在每個頁面都使用 Scaffold 這個 Widget 包覆內容。

最後我們在 MyApp 內的 MaterialApp 插入路由規則,要注意的是當 routes 內包含 \ 時,外面的 home 屬性就不能設定,反之亦然,擇一使用。

完整程式如下:

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,
    );
  }
}

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

後記

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