Flutter實現一個酷炫帶動畫的列表型多選日曆組件

  • 2020 年 3 月 27 日
  • 筆記

由於項目需要,用Flutter重構了之前用Android做過的日曆組件,整體效果感覺不錯,流暢度甚至超過原來的,這裡需要提一下官網的做法,如下:

    var date = DateTime.now();      return showDatePicker(        context: context,        initialDate: date,        firstDate: date,        lastDate: date.add(          Duration(days: 30),        ),      );  複製程式碼

官方的做法就是showDatePicker實現的,支援MD和IOS的風格,但據我了解,只支援單選,不支援開始和結束日期的區間選擇,體驗也與我需要的效果不一致,所以經過考慮之後,還是決定自己寫一個。

先上效果圖

實現的功能和需求

  1. 繪製「日」,「月」,「年」組件,年嵌套多個月,月嵌套多個周,然後再是天
  2. 繪製日曆頭部與底部確認選擇按鈕
  3. 支援某一天單選,開始日期和結束日期多選,反向選擇(先選結束日期再選開始日期),跨月選擇,取消選擇等事件
  4. 對外暴露CalendarList組件,這個組件是List類型,也就是說它是多個月的集合

下面分段對部分程式碼進行描述。

先從調用入口進行分析

下面就是一個日曆選擇組件的調用方式:

return CalendarList(    firstDate: DateTime(2019, 8),    lastDate: DateTime(2020, 8),    selectedStartDate: DateTime(2019, 8, 28),    selectedEndDate: DateTime(2019, 9, 2),    onSelectFinish: (selectStartTime, selectEndTime) {      List<DateTime> result = <DateTime>[];      result.add(selectStartTime);      if (selectEndTime != null) {        result.add(selectEndTime);      }      Navigator.pop(context, result);    },  );  複製程式碼

其中firstDate和lastDate是選擇的月份列表,本例中,從2019年8月開始算起,結束時間是2020年8月,然後又有2個參數selectedStartDate和selectedEndDate,這2個參數是給定的默認選中區間,本例中默認選中了2019/8/28和2019/9/2之間的所有日期,默認選中一般是記錄用戶上次選中的結果。onSelectFinish就是選完之後的回調,以上這些參數是根據實際業務可以靈活設置的。

底部彈出方式的日期方式

這塊其實很簡單,CalendarList本身就支援從底部滑出,調用的方法是showModalBottomSheet,程式碼如下:

    showModalBottomSheet(        context: context,        builder: (BuildContext context) {          return Container(            height: 600.0,            child: FullScreenDemo(),          );        },      ).then((result) {        setState(() {          selectResult2 = result;        });      });  複製程式碼

其中日曆放在了FullScreenDemo里,通過Container包一層設置一個高度,然後就可以通過showModalBottomSheet方法從底部滑出。

CalendarList滾動列表繪製

通過上面的講述,我們了解了如何使用CalendarList組件,那麼我們看看源碼裡面具體做了哪些。筆者在實現該功能時把MonthView作為SliverList的一個build item。放置到CustomScrollView的Sliver裡面,這裡複習一下,Sliver的作用其實就是「粘合劑」的作用,把多個組件粘合起來形成一個滾動區域,布局如下:

CustomScrollView(      slivers: <Widget>[        SliverList(          delegate: SliverChildBuilderDelegate(            (BuildContext context, int index) {              int month = index + monthStart;              DateTime calendarDateTime = DateTime(yearStart, month);              return _getMonthView(calendarDateTime);            },            childCount: count,          ),        ),      ],  ),  複製程式碼

在BuildContext中,通過index與monthStart想加,計算出日曆,即8,9,10,11…這些月份,需要注意的是DateTime裡面傳入的month參數如果超過了12,則前面的年會自動「進位」(Flutter設置的太貼心了),好了,在_getMonthView裡面,我們看看return了一個什麼樣的Widget,程式碼如下:

  Widget _getMonthView(DateTime dateTime) {      int year = dateTime.year;      int month = dateTime.month;      return MonthView(        context: context,        year: year,        month: month,        padding: HORIZONTAL_PADDING,        dateTimeStart: selectStartTime,        dateTimeEnd: selectEndTime,        todayColor: Colors.deepOrange,        onSelectDayRang: (dateTime) => onSelectDayChanged(dateTime),      );    }  複製程式碼

好,這裡就是傳入了MonthView,設置了年、月,dateTimeStart,dateTimeEnd,today高亮顏色這些參數。下面,我們看看MonthView裡面又做了啥

MonthView繪製

MonthView其實就是真正繪製每個月有多少個星期,然後每個星期的7天展示,通過每行(Row)放置7個DayNumber組件,根據每周循環出整個月的數據,程式碼片段如下:

      dayRowChildren.add(          DayNumber(            size: widget.itemWidth,            day: day,            isToday: isToday,            isDefaultSelected: isDefaultSelected,            todayColor: widget.todayColor,            onDayTap: (day) {              selectedDate = DateTime(widget.year, widget.month, day);              widget.onSelectDayRang(selectedDate);            },          ),        );          if ((day - 1 + firstWeekdayOfMonth) % DateTime.daysPerWeek == 0 ||            day == daysInMonth) {          dayRows.add(            Row(              children: List<DayNumber>.from(dayRowChildren),            ),          );          dayRowChildren.clear();        }  複製程式碼

這樣,一個日曆就出來了,不過光有這些是不行的,因為還沒開始做選擇器,即(單選,多選,反選,取消這些),需要高亮出來,高亮的邏輯大致如下:

      DateTime moment = DateTime(widget.year, widget.month, day);        final bool isToday = dateIsToday(moment);          bool isDefaultSelected = false;        if (widget.dateTimeStart == null &&            widget.dateTimeEnd == null &&            selectedDate == null) {          isDefaultSelected = false;        }        if (widget.dateTimeStart == selectedDate &&            widget.dateTimeEnd == null &&            selectedDate?.day == day &&            day > 0) {          isDefaultSelected = true;        }        if (widget.dateTimeStart != null && widget.dateTimeEnd != null) {          isDefaultSelected = (moment.isAtSameMomentAs(widget.dateTimeStart) ||                      moment.isAtSameMomentAs(widget.dateTimeEnd)) ||                  moment.isAfter(widget.dateTimeStart) &&                      moment.isBefore(widget.dateTimeEnd) &&                      day > 0              ? true              : false;        }  複製程式碼

上述程式碼可以說是一部分核心邏輯,會根據CalendarList傳入的選擇區間通過DateTime moment進行篩選,如果是在區間範圍內,則選中該區間,猜猜怎麼讓DayNumber高亮起來? OK,其實知道了高亮區間之後,在DayNumber里就可以傳入默認選中isDefaultSelected,下面,我們看看DayNumber又做了啥

DayNumber繪製

和CalendarList,MonthView比起來,DayNumber就是小弟了,具體的繪製程式碼如下:

  Widget _dayItem() {      return Container(        width: widget.size - itemMargin * 2,        height: widget.size - itemMargin * 2,        margin: EdgeInsets.all(itemMargin),        alignment: Alignment.center,        decoration: (isSelected && widget.day > 0)            ? BoxDecoration(color: Colors.blue)            : widget.isToday ? BoxDecoration(color: widget.todayColor) : null,        child: Text(          widget.day < 1 ? '' : widget.day.toString(),          textAlign: TextAlign.center,          style: TextStyle(            color: (widget.isToday || isSelected) ? Colors.white : Colors.black87,            fontSize: 15.0,            fontWeight: FontWeight.normal,          ),        ),      );    }  複製程式碼

其中Container裡面聲明了decoration,通過BoxDecoration設置了背景色,程式碼中把選中的效果優先於today高亮色,這樣就可以覆蓋當天的顏色,具體的Day則是Text繪製的。

通過上面的描述,我們了解了Calendar,MonthView,DayNumber三者的關係,核心程式碼差不多就這些吧。

下面,我們再看看單選,多選,反選,取消這些邏輯是怎麼實現的

單選,多選,反選,取消邏輯實現

程式碼有點長,先貼出來,然後我們分析一下:

  // 選項處理回調    void onSelectDayChanged(dateTime) {      if (selectStartTime == null && selectEndTime == null) {        selectStartTime = dateTime;      } else if (selectStartTime != null && selectEndTime == null) {        selectEndTime = dateTime;        // 如果選擇的開始日期和結束日期相等,則清除選項        if (selectStartTime == selectEndTime) {          setState(() {            selectStartTime = null;            selectEndTime = null;          });          return;        }        // 如果用戶反選,則交換開始和結束日期        if (selectStartTime?.isAfter(selectEndTime)) {          DateTime temp = selectStartTime;          selectStartTime = selectEndTime;          selectEndTime = temp;        }      } else if (selectStartTime != null && selectEndTime != null) {        selectStartTime = null;        selectEndTime = null;        selectStartTime = dateTime;      }      setState(() {        selectStartTime;        selectEndTime;      });    }  複製程式碼

onSelectDayChanged其實就是對用戶點擊DayNumber行為的事件回調,這是一個典型的子組件調用父組件改變其狀態的程式碼段,通過selectStartTime和selectEndTime是否為null判斷用戶的點擊行為落在哪個if else裡面,通過setState重新設置開始和結束日期,這樣就可以「刷新」MonthView裡面的DayNumber選擇範圍,好了,大致的核心源碼就分析到這裡。

總結一下,通過本例可以學習到以下知識點

  1. 路由參數傳遞和參數回傳
  2. 父子組件正向與逆向通訊
  3. 日期函數DateTime的運用
  4. Sliver在CustomScrollView中的運用
  5. 日曆繪製方式
  6. 底部彈出組件使用方式
  7. 其他各種布局技巧及細節

可以改善的地方

  1. 國際化支援
  2. 自定義顏色傳入
  3. 後續發布到Flutter Pub

程式碼地址

本例中相關的程式碼放在

github地址:github.com/heruijun/fl…

此例已經作為補充內容添加至我的《Flutter從0到1構建大前端應用》一書的源碼中,是一個知識點比較多的綜合案例,再版時會根據讀者意見考慮加入到書中講解。