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的風格,但據我了解,只支援單選,不支援開始和結束日期的區間選擇,體驗也與我需要的效果不一致,所以經過考慮之後,還是決定自己寫一個。
先上效果圖

實現的功能和需求
- 繪製「日」,「月」,「年」組件,年嵌套多個月,月嵌套多個周,然後再是天
- 繪製日曆頭部與底部確認選擇按鈕
- 支援某一天單選,開始日期和結束日期多選,反向選擇(先選結束日期再選開始日期),跨月選擇,取消選擇等事件
- 對外暴露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選擇範圍,好了,大致的核心源碼就分析到這裡。
總結一下,通過本例可以學習到以下知識點
- 路由參數傳遞和參數回傳
- 父子組件正向與逆向通訊
- 日期函數DateTime的運用
- Sliver在CustomScrollView中的運用
- 日曆繪製方式
- 底部彈出組件使用方式
- 其他各種布局技巧及細節
可以改善的地方
- 國際化支援
- 自定義顏色傳入
- 後續發布到Flutter Pub
程式碼地址
本例中相關的程式碼放在
github地址:github.com/heruijun/fl…
此例已經作為補充內容添加至我的《Flutter從0到1構建大前端應用》一書的源碼中,是一個知識點比較多的綜合案例,再版時會根據讀者意見考慮加入到書中講解。