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

因爲項目須要,用Flutter重構了以前用Android作過的日曆組件,總體效果感受不錯,流暢度甚至超過原來的,這裏須要提一下官網的作法,以下:html

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類型,也就是說它是多個月的集合

下面分段對部分代碼進行描述。vue

先從調用入口進行分析

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

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就是選完以後的回調,以上這些參數是根據實際業務能夠靈活設置的。git

底部彈出方式的日期方式

這塊其實很簡單,CalendarList自己就支持從底部滑出,調用的方法是showModalBottomSheet,代碼以下:github

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

其中日曆放在了FullScreenDemo裏,經過Container包一層設置一個高度,而後就能夠經過showModalBottomSheet方法從底部滑出。bash

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構建大前端應用》一書的源碼中,是一個知識點比較多的綜合案例,再版時會根據讀者意見考慮加入到書中講解。

新書推薦

你們好,下面插播一條廣告,我是《Flutter從0到1構建大前端應用》的做者,感謝已經購買的讀者,此書屬於入門上手的書籍,以簡單明瞭的代碼實例說明問題,也便於讀者查閱相關內容。

從Flutter基礎開始講解,結合實際案例,讓讀者逐步掌握Flutter的核心內容,實戰項目篇又經過2個實戰項目讓讀者除了掌握Flutter相關知識以外,對node、mongo,vue作了一些介紹,可讓更多的讀者擁抱目前最火的大前端技術。

京東購買連接:item.jd.com/12546599.ht…

相關文章
相關標籤/搜索