[譯] 挑戰 Flutter 之 YouTube(畫中畫)

MeFelixWang發表於2019-03-04

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

挑戰 Flutter 嘗試在 Flutter 中重新建立特定應用的 UI 或設計。

此挑戰將嘗試實現 YouTube 的主頁和視訊詳情頁(視訊實際播放的頁面),包括動畫。

這個挑戰將比我以前的挑戰稍微複雜一些,但結果卻更好。

開始

YouTube 應用包括:

a)主頁包括:

  1. AppBar 中有三個 action
  2. 使用者訂閱視訊
  3. 底部導航欄

b)視訊詳情頁包括:

  1. 可縮小的主播放器,能讓使用者檢視他們的訂閱資訊(PIP)
  2. 基於當前視訊的使用者推薦

建立專案

讓我們建立一個名為 youtube_flutter 的 Flutter 專案,並刪除所有預設程式碼,只留下一個帶有預設 appBar 的空白頁面。

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {

  @override
  _MyHomePageState createState() => new _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      appBar: new AppBar(
        title: new Text(""),
      ),
      body: new Center(
        child: new Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
          ],
        ),
      ),
    );
  }
}
複製程式碼

製作 AppBar

AppBar 左側有 YouTube 的 logo 和名稱,右側有三個 action,即記錄、搜尋和開啟配置檔案。

重新建立 AppBar:

appBar: new AppBar(
  backgroundColor: Colors.white,
  title: Row(
    mainAxisSize: MainAxisSize.min,
    children: <Widget>[
      Icon(FontAwesomeIcons.youtube, color: Colors.red,),
      Padding(
        padding: const EdgeInsets.only(left: 8.0),
        child: Text("YouTube", style: TextStyle(color: Colors.black, letterSpacing: -1.0, fontWeight: FontWeight.w700),),
      ),
    ],
  ),
  actions: <Widget>[
    Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12.0),
      child: Icon(Icons.videocam, color: Colors.black54,),
    ),
    Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12.0),
      child: Icon(Icons.search, color: Colors.black54,),
    ),
    Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12.0),
      child: Icon(Icons.account_circle, color: Colors.black54,),
    ),
  ],
),
複製程式碼

這就是重新建立的 AppBar 的樣子:

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

注意:對於 YouTube 的 logo,我使用了 Dart pub FontFlutterAwesome 圖示。

接著製作底部導航欄,

建立 BottomNavigationBar

底部導航欄有5項,在 Flutter 中重新建立非常簡單。我們使用 Scaffold 的 bottomNavigationBar 引數。

bottomNavigationBar: BottomNavigationBar(items: [
  BottomNavigationBarItem(icon: Icon(Icons.home, color: Colors.black54,), title: Text("Home", style: TextStyle(color: Colors.black54),),),
  BottomNavigationBarItem(icon: Icon(FontAwesomeIcons.fire, color: Colors.black54,), title: Text("Home", style: TextStyle(color: Colors.black54),),),
  BottomNavigationBarItem(icon: Icon(Icons.subscriptions, color: Colors.black54,), title: Text("Home", style: TextStyle(color: Colors.black54),),),
  BottomNavigationBarItem(icon: Icon(Icons.email, color: Colors.black54,), title: Text("Home", style: TextStyle(color: Colors.black54),),),
  BottomNavigationBarItem(icon: Icon(Icons.folder, color: Colors.black54,), title: Text("Home", style: TextStyle(color: Colors.black54),),),
], type: BottomNavigationBarType.fixed,),
複製程式碼

注意:對於4個以上的專案我們需要指定一個固定的 BottomNavigationBarType,因為為了避免擁擠預設型別是 shifting。

結果是:

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

重新建立的 YouTube 底部導航欄

使用者訂閱視訊

使用者訂閱視訊是由推薦視訊組成的專案列表。我們來看看列表項:

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

列表項由一個帶有一張圖片的 Column 和一個有關視訊資訊的 Raw 組成。該 Row 由一張圖片,一個包含標題、釋出者和選單按鈕的 Column 組成。

要在 Flutter 中建立列表,我們可以使用 ListView.builder()。重新建立列表項,如下:

ListView.builder(
  itemCount: 3,
  itemBuilder: (context, position) {
    return Column(
      children: <Widget>[
        Row(
          children: <Widget>[
            Expanded(child: Image.asset(videos[position].imagePath, fit: BoxFit.cover,)),
          ],
        ),
        Padding(
          padding: const EdgeInsets.all(12.0),
          child: Row(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Expanded(child: Icon(Icons.account_circle, size: 40.0,), flex: 2,),
              Expanded(
                child: Column(
                  children: <Widget>[
                    Padding(
                      padding: const EdgeInsets.only(bottom: 4.0),
                      child: Text(videos[position].title, style: TextStyle(fontSize: 18.0),),
                    ),
                    Text(videos[position].publisher, style: TextStyle(color: Colors.black54),)
                  ],
                  crossAxisAlignment: CrossAxisAlignment.start,
                ),
                flex: 9,
              ),
              Expanded(child: Icon(Icons.more_vert), flex: 1,),
            ],
          ),
        )
      ],
    );
  },
),
複製程式碼

這裡的視訊只是包含由標題和釋出者等視訊詳情的列表。

這是重新建立的主頁的樣子:

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

我們重新建立的主頁

現在,我們將繼續討論稍微難一點的部分,視訊詳情頁。

建立視訊詳情頁

視訊詳情頁才是在 YouTube 中真正展示視訊的頁面。頁面的亮點是我們可以縮小視訊,並在螢幕的右下角繼續播放。對於本文,我們將專注於縮小動畫而不是實際播放視訊。

請注意,這並不是一個特別的頁面,而是在現有螢幕上疊加覆蓋層。因此,我們將使用 Stack 元件來覆蓋螢幕。

所以在背後,將有我們的主頁,而頂部將是我們的視訊頁面。

構建浮動視訊播放器(畫中畫)

為了構建可以擴大至填充整個螢幕的浮動視訊播放器,我們使用 LayoutBuilder 來完美地適配螢幕。

在繼續之前,我們先定義一些值,即縮小和擴大時視訊播放器的大小。我們不用為擴大的播放器設定寬度,而是從佈局構建器中獲取。

var currentAlignment = Alignment.topCenter;

var minVideoHeight = 100.0;
var minVideoWidth = 150.0;

var maxVideoHeight = 200.0;

// 這是一個任意的值,當構建佈局時會改變。
var maxVideoWidth = 250.0;

var currentVideoHeight = 200.0;
var currentVideoWidth = 200.0;

bool isInSmallMode = false;
複製程式碼

這裡, “small mode” 指視訊播放器縮小的時候。

構建視訊詳情頁的 LayoutBuilder 可以寫成:

LayoutBuilder(
  builder: (context, constraints) {

    maxVideoWidth = constraints.biggest.width;

    if(!isInSmallMode) {
      currentVideoWidth = maxVideoWidth;
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.end,
      children: <Widget>[
        Expanded(
          child: Align(
            child: Padding(
              padding: EdgeInsets.all(isInSmallMode? 8.0 : 0.0),
              child: GestureDetector(
                child: Container(
                  width: currentVideoWidth,
                  height: currentVideoHeight,
                  child: Image.asset(
                    videos[videoIndexSelected].imagePath,
                    fit: BoxFit.cover,),
                  color: Colors.blue,
                ),
                onVerticalDragEnd: (details) {
                  if(details.velocity.pixelsPerSecond.dy > 0) {
                    setState(() {
                      isInSmallMode = true;
                    });
                  }else if (details.velocity.pixelsPerSecond.dy < 0){
                    setState(() {
                    });
                  }
                },
              ),
            ),
            alignment: currentAlignment,
          ),
          flex: 3,
        ),
        currentAlignment == Alignment.topCenter ?
        Expanded(
          flex: 6,
          child: Container(
            child: Column(
              children: <Widget>[
                Row(),
                Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Card(
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text("Video Recommendation"),
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Card(
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text("Video Recommendation"),
                    ),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.all(8.0),
                  child: Card(
                    child: Padding(
                      padding: const EdgeInsets.all(8.0),
                      child: Text("Video Recommendation"),
                    ),
                  ),
                )
              ],
            ),
            color: Colors.white,
          ),
        )
            :Container(),
        Row(),
      ],
    );
  },
)
複製程式碼

注意我們是如何獲得最大螢幕寬度然後使用,而不是使用我們的第一個任意值來作為最大螢幕寬度。

我們附加了一個 GestureDetector 來檢測螢幕上的滑動,以便我們可以相應地縮小和擴大它。讓我們建立動畫。

為視訊詳情頁新增動畫

當我們製作動畫時,需要處理兩件事:

  1. 將視訊從右上角移動到右下角。
  2. 更改視訊的大小並使其變小。

對於這些東西,我們使用兩個 Tweens,一個 AlignmentTween 和一個 Tween,並構造兩個同時執行的獨立動畫。

AnimationController alignmentAnimationController;
Animation alignmentAnimation;

AnimationController videoViewController;
Animation videoViewAnimation;

var currentAlignment = Alignment.topCenter;
@override
void initState() {
  super.initState();

  alignmentAnimationController = AnimationController(vsync: this, duration: Duration(seconds: 1))
    ..addListener(() {
      setState(() {
        currentAlignment = alignmentAnimation.value;
      });
    });
  alignmentAnimation = AlignmentTween(begin: Alignment.topCenter, end: Alignment.bottomRight).animate(CurvedAnimation(parent: alignmentAnimationController, curve: Curves.fastOutSlowIn));

  videoViewController = AnimationController(vsync: this, duration: Duration(seconds: 1))
    ..addListener(() {
      setState(() {
        currentVideoWidth = (maxVideoWidth*videoViewAnimation.value) + (minVideoWidth*(1.0-videoViewAnimation.value));
        currentVideoHeight = (maxVideoHeight*videoViewAnimation.value) + (minVideoHeight*(1.0-videoViewAnimation.value));
      });
    });
  videoViewAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(videoViewController);

}
複製程式碼

當我們的視訊播放器向上或向下滑動時,會觸發這些動畫。

onVerticalDragEnd: (details) {
  if(details.velocity.pixelsPerSecond.dy > 0) {
    setState(() {
      isInSmallMode = true;
      alignmentAnimationController.forward();
      videoViewController.forward();
    });
  }else if (details.velocity.pixelsPerSecond.dy < 0){
    setState(() {
      alignmentAnimationController.reverse();
      videoViewController.reverse().then((value) {
        setState(() {
          isInSmallMode = false;
        });
      });
    });
  }
},

複製程式碼

這是程式碼的最終結果:

[譯] 挑戰 Flutter 之 YouTube(畫中畫)

最終重新建立的 YouTube 應用

以下是該應用的視訊:

最終應用的 iOS 視訊

這是該專案的 GitHub 連結:github.com/deven98/You…

感謝閱讀此 Flutter 挑戰。可以留言告訴我任何你想要在 Flutter 中重新建立的應用。喜歡請給個 star,下次見。

不要錯過:

Flutter Challenge: Whatsapp

Flutter Challenge: Twitter

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章