[Flutter] Custom CupertinoDatePicker into YearPicker
Hello mọi người. Tình hình là hôm bữa mình có làm dự án, cần một picker để chọn mỗi năm. Mình đã sử dụng YearPicker để chỉ có năm hiển thị. Nhưng mà ông Tuấn, leader team mình kêu nó xấu, phải làm cái khác. Và đồng thời phải custom giống với bên IOS không Apple nó từ chối app thì mệt. Chà chà, thế thì mình phải dùng CupertinoDatePicker thôi. Nhưng mà, vấn đề nó ở đây. Thằng CupertinoDatePicker nó hỗ trợ 3 mode: date, time, dateAndTime. Và tất nhiên là chả có cái nào chỉ hiển thị mỗi năm. Căng thế nhờ. Làm sao ta?
Sau một hồi loanh quanh, mày mò khắp ngõ ngách trên internet, thì có một cách ấy là lôi cái file CupertinoDatePicker ra rồi custom nó. Ok. Vậy là ngon rồi. Có hướng để làm rồi. Và sau đó, là cả một quá trình mình ngồi ngâm cứu code và sửa code để có được một kết quả như ý. Đâu đó mất hơn ngày. Và nhờ đó, mình đã có bài blog này cho ae tham khảo. Nào, cùng xem mình đã làm như nào. Let's go....
Đầu tiền, mình gọi CupertinoDatePicker để tìm đến file date_picker.dart.
void showPicker() {
showCupertinoModalPopup(
context: context,
builder: (_) => Container(
height: 500,
color: Palette.grayBackground,
child: Column(
children: [
Container(
height: 400,
child: CupertinoDatePicker(
mode: PickerExt.CupertinoDatePickerMode.date,
initialDateTime: DateTime.now(),
onDateTimeChanged: (val) {}),
),
// Close the modal
],
),
),
);
}
Chính xác thì nó ở
external libraries/dart packages/flutter/src/cupertino/date_picker.dart
Mình copy toàn bộ file này ra một file khác, đặt tên nó là custom_picker.dart. Nó sẽ báo đỏ một vài dòng, các bạn chỉ cần thêm dòng dưới và xóa các import thừa đi là được.
import 'package:flutter/cupertino.dart';
Tiếp theo quay lại file cần gọi picker, các bạn sẽ cần import file picker_custom và gọi thằng CupertinoDatePicker trong file custom thay vì trong thư viện gốc của flutter. Hãy đặt tên cho file được import để thuận tiện cho việc gọi hàm trong file ấy ra nhé.
import 'custom_picker.dart' as PickerExt;
void showPicker() {
showCupertinoModalPopup(
context: context,
builder: (_) => Container(
height: 500,
color: Palette.grayBackground,
child: Column(
children: [
Container(
height: 400,
child: PickerExt.CupertinoDatePicker(
mode: PickerExt.CupertinoDatePickerMode.date,
initialDateTime: DateTime.now(),
onDateTimeChanged: (val) {}),
),
// Close the modal
],
),
),
);
}
Sau khi hiển thị được CupertinoDatePicker từ file custom, giờ là lúc các bạn custom lại CupertinoDatePicker.
Đầu tiên, các bạn tìm đến Widget _buildYearPicker.
Widget _buildYearPicker(double offAxisFraction, TransitionBuilder itemPositioningBuilder, Widget selectionOverlay) {
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification is ScrollStartNotification) {
isYearPickerScrolling = true;
} else if (notification is ScrollEndNotification) {
isYearPickerScrolling = false;
_pickerDidStopScrolling();
}
return false;
},
child: CupertinoPicker.builder(
scrollController: yearController,
itemExtent: _kItemExtent,
offAxisFraction: offAxisFraction,
useMagnifier: _kUseMagnifier,
magnification: _kMagnification,
backgroundColor: widget.backgroundColor,
onSelectedItemChanged: (int index) {
selectedYear = index;
if (_isCurrentDateValid)
widget.onDateTimeChanged(DateTime(selectedYear, selectedMonth, selectedDay));
},
itemBuilder: (BuildContext context, int year) {
if (year < widget.minimumYear)
return null;
if (widget.maximumYear != null && year > widget.maximumYear!)
return null;
final bool isValidYear = (widget.minimumDate == null || widget.minimumDate!.year <= year)
&& (widget.maximumDate == null || widget.maximumDate!.year >= year);
return itemPositioningBuilder(
context,
Text(
localizations.datePickerYear(year),
style: _themeTextStyle(context, isValid: isValidYear),
),
);
},
selectionOverlay: selectionOverlay,
),
);
}
Trên dưới hàm này còn các widget _buildMonthPicker hay _buildDayPicker. Đây là các đoạn code để build lên các cột picker. Các bạn có thể xóa các cột kia đi hoặc để lại. Vì mình chỉ dùng pickerYear nên mình sẽ xóa các hàm build kia đi cho gọn code. Sau đó các bạn xuống hàm build ở dòng 1243 hoặc click vào _buildYearPicker() sẽ trỏ đến chỗ ColumBuilder. Đoạn này quyết định build lên bao nhiêu cột nhé. Xóa các đoạn build của tháng và ngày đi. Để lại năm thôi nhé
switch (localizations.datePickerDateOrder) {
case DatePickerDateOrder.mdy:
pickerBuilders = <_ColumnBuilder>[_buildYearPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.year.index]!,
];
break;
case DatePickerDateOrder.dmy:
pickerBuilders = <_ColumnBuilder>[ _buildYearPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.dayOfMonth.index]!,
];
break;
case DatePickerDateOrder.ymd:
pickerBuilders = <_ColumnBuilder>[_buildYearPicker];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.year.index]!,
];
break;
case DatePickerDateOrder.ydm:
pickerBuilders = <_ColumnBuilder>[_buildYearPicker,];
columnWidths = <double>[
estimatedColumnWidths[_PickerColumnType.year.index]!,
];
break;
}
Rồi. Giờ chúng ta sẽ build thử xem nó thế nào
Đừng lo lắng, các bạn có thể nhìn vào đoạn code tiếp theo. Mình gọi đoạn này này là buildColumPicker nha.
final List<Widget> pickers = <Widget>[];
for (int i = 0; i < columnWidths.length; i++) {
final double offAxisFraction = (i - 1) * 0.3 * textDirectionFactor;
EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize);
if (textDirectionFactor == -1)
padding = const EdgeInsets.only(left: _kDatePickerPadSize);
Widget selectionOverlay = _centerSelectionOverlay;
if (i == 0)
selectionOverlay = _leftSelectionOverlay;
else if (i == columnWidths.length - 1)
selectionOverlay = _rightSelectionOverlay;
pickers.add(LayoutId(
id: i,
child: pickerBuilders[i](
offAxisFraction,
(BuildContext context, Widget? child) {
return Container(
alignment: i == columnWidths.length - 1
? alignCenterLeft
: alignCenterRight,
padding: i == 0 ? null : padding,
child: Container(
alignment: i == 0 ? alignCenterLeft : alignCenterRight,
width: columnWidths[i] + _kDatePickerPadSize,
child: child,
),
);
},
selectionOverlay,
),
));
}
Vì lúc trước có 3 cột, nên cần vòng for. Nhưng giờ chứng ta chỉ có 1 cột, nên chúng ta có thể bỏ vòng for đi nhé các bạn. Hoặc các bạn cứ để vậy cũng được. Nhìn vào đoạn code trên nhé, các bạn có thấy biến offAxisFraction chứ. Biến này quyết định độ nghiêng của con lăn. Tại i = 0. Nó sẽ nghiêm sang trái, i = 1 nó sẽ chính diện và i = 2 nó sẽ nghiêm sang bên phải. Lúc này thì mình chỉ cần gán offAxisFraction = 0 là được. Tiếp theo là đến wiget _selectionOverlay các bạn để mặc định là _centerSelectionOverlay luôn nhé. Bỏ mấy cái điều kiện kia đi nhé. Thực sự thì biến này mình không thấy có tác động nhiều. Nhưng mình nghĩ vẫn nên để là _centerSelectionOverlay. Nếu các bạn bỏ vòng lặp for thì nhớ đổi i => 0 nhé, còn không thì kệ thôi. Các alignment của container thì các bạn để mặc định là Alignment.center nhé. Code cuối sẽ cùng sẽ như này nhé:
final List<Widget> pickers = <Widget>[];
final double offAxisFraction = 0;
EdgeInsets padding = const EdgeInsets.only(right: _kDatePickerPadSize);
if (textDirectionFactor == -1)
padding = const EdgeInsets.only(left: _kDatePickerPadSize);
Widget selectionOverlay = _centerSelectionOverlay;
pickers.add(LayoutId(
id: 0,
child: pickerBuilders[0](
offAxisFraction,
(BuildContext context, Widget? child) {
return Container(
alignment: Alignment.center,
padding: null,
child: Container(
alignment: Alignment.center,
width: columnWidths[0] + _kDatePickerPadSize,
child: child,
),
);
},
selectionOverlay,
),
));
Và kết quả chúng ta nhận được là :
Vậy là các bạn thấy, chúng ta có một YearPickerCupertino rồi đúng không. Nhưng mà chưa xong đâu các bạn. Nhìn nhé, các bạn có thấy nó đang lẹm về bên trái của màn hình không. Các bạn nghĩ mình chỉ cần gọi Center bọc cho nó là được đúng không. Rất tiếc là không các bạn ạ. Thằng picker này không chịu bất cứ tác động nào từ bên ngoài: "Tâm bất biến giữa dòng đợi vạn biến".Mọi nỗ lực bọc Center() của mình đều bất thành. Và khi tưởng trừng như bất lực thì mình đã quyết định nghiên cứu kĩ hơn các đoạn code. Và ồ, tất nhiên rồi, mình đã đào ra được vấn đề. Nó lại có một biến quyết định vị trí của mình trên màn hình. Để mình chỉ cho nhé. Trước tiên mình sẽ thêm màu cho background của các thành phần của cái picker này, để các bạn hiểu bố cục nó như nào nhé. Tại Cupertino.builder mình sẽ thêm màu cam, tại đoạn buildColumPicker (mình đặt tên phía trên đó), các container mình đặt màu xanh và đỏ, xuống dưới đoạn code return MediaQuery(), mình bọc CustomMultiChildLayout() vào một Container() và thêm cho nó màu xanh ngọc. Cùng nhìn qua kết quả để hiểu hơn các đoạn code build lên cái gì nhé các bạn.
Các bạn thấy đó, phần màu cam không hề kéo hết phần màu xanh, và phần màu xanh không hề kéo hết phần màu xám (phần màu xám là cái dialog được gọi để chứa picker). Và phần màu cam kia nằm ở đâu trên màu xanh thì lại do cái biến quyết định vị trị. Vậy nên, khi mình cố gắng gắn Center() trong cái màu xanh ngọc thì nó thật bại không chút thay đổi. Bây giờ ở trong cái Container mình mới bọc cho CustomMultiChildLayout(), mình sẽ đạt chiều rộng và chiều cao cho nó. Chiều cao thì tùy các bạn bạn muốn nó cao bằng nào, mình thì mình để bằng 1/3 màn hình (sử dụng MediaQuery.of(context).size.height / 3 được nhé các bạn). Còn chiều rộng thì các bạn để 250 nhé. Thực ra con số này chỉ quyết định chiều rộng của phần màu xanh thôi. Các bạn có thể chọn một giá trị cố định nào đấy. Mình đã thử lấy theo tỉ lệ chiều rộng của màn hình nhưng sau đó thay số vào biến quyết định vị trí của cột màu cam không được như mong đợi. Nên mình quyết định giữ nguyên số 250 này. Các bạn có thể nghiên cứu thêm chỗ này để gán theo tỉ lệ màn hình. Bây giờ chúng ta sẽ ctrl+chuột trái vào _DatePickerLayoutDelegate(). Các bạn kéo chậm xuống một chút tìm positionChild(index, Offset(currentHorizontalOffset, 0.0));. Các bạn thay giá trị currentHorizontalOffset = (size.width / 2) - 80. Thì mục đích của cái này là lấy ra vị trí chính giữa của phần màu xanh. Vì độ rộng của cái phần màu xanh là 250 nên sẽ bằng (size.width / 2) - 80. Và kết quả chúng ta nhận được lúc này sẽ là:
Vậy là xong rồi đấy, việc còn lại các bạn chỉ cần loại bỏ màu của các nền kia đi và để về màu trắng là được. Và đây là kết quả cuối cùng khi mình sử dụng vào dự án.
Lần giải quyết vấn đề này đã giúp mình có thêm kinh nghiệm xử lí rất nhiều. Vì lần đầu trọc ngoáy và không có nhiều thời gian để làm mịn code và tối ưu code nên có thể có những thiếu sót. Các bạn có thể nghiên cứu thêm và tối ưu code lại. Hẹn gặp lại các bạn trong những bài khác.
Đây là file code mình đã xóa đi đa số những phần code không cần thiết. Các bạn có thể tham khảo: