前言
SideBar是APP開發當中常見的功能之一,多用於索引列表,如城市選擇,分類等。在優化OpenGit趨勢列表時,由於在選擇語言時需要用到這樣的控制元件,嘗試開發了這個控制元件,效果如下圖所示
準備
完成SideBar需要向外提供以下引數
- SideBar寬以及每個letter的高度;
- 預設背景色和文字顏色;
- 按下時的背景色和文字顏色;
- 當前選中letter的回撥;
- 索引列表;
選中letter的回掉函式如下所示
typedef OnTouchingLetterChanged = void Function(String letter);
複製程式碼
索引列表資料如下面程式碼所示
const List<String> A_Z_LIST = const [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"#"
];
複製程式碼
當按下SideBar時需要重新整理UI,所以SideBar需要繼承StatefulWidget
,建構函式如下所示
class SideBar extends StatefulWidget {
SideBar({
Key key,
@required this.onTouch,
this.width = 30,
this.letterHeight = 16,
this.color = Colors.transparent,
this.textStyle = const TextStyle(
fontSize: 12.0,
color: Color(YZColors.subTextColor),
),
this.touchDownColor = const Color(0x40E0E0E0),
this.touchDownTextStyle = const TextStyle(
fontSize: 12.0,
color: Color(YZColors.mainTextColor),
),
});
final int width;
final int letterHeight;
final Color color;
final Color touchDownColor;
final TextStyle textStyle;
final TextStyle touchDownTextStyle;
final OnTouchingLetterChanged onTouch;
}
複製程式碼
封裝SideBar
在_SideBarState
中,需要通過touch的狀態來判斷背景色的展示,相關程式碼如下所示
class _SideBarState extends State<SideBar> {
bool _isTouchDown = false;
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
color: _isTouchDown ? widget.touchDownColor : widget.color,
width: widget.width.toDouble(),
child: _SlideItemBar(
letterWidth: widget.width,
letterHeight: widget.letterHeight,
textStyle: _isTouchDown ? widget.touchDownTextStyle : widget.textStyle,
onTouch: (letter) {
if (widget.onTouch != null) {
setState(() {
_isTouchDown = !TextUtil.isEmpty(letter);
});
widget.onTouch(letter);
}
},
),
);
}
}
複製程式碼
上面程式碼,主要部分通過_SlideItemBar
的letter狀態的改變來重新整理Container
的color,下面看下_SlideItemBar
的實現,相關程式碼如下所示
class _SlideItemBar extends StatefulWidget {
final int letterWidth;
final int letterHeight;
final TextStyle textStyle;
final OnTouchingLetterChanged onTouch;
_SlideItemBar(
{Key key,
@required this.onTouch,
this.letterWidth = 30,
this.letterHeight = 16,
this.textStyle})
: assert(onTouch != null),
super(key: key);
@override
_SlideItemBarState createState() {
return _SlideItemBarState();
}
}
複製程式碼
上文程式碼,沒有做過多的操作,只是定義了幾個變數,詳細的操作在_SlideItemBarState
。
在_SlideItemBarState
中,需要知道每個letter
在垂直方向上的偏移高度,如下面程式碼所示
void _init() {
_letterPositionList.clear();
_letterPositionList.add(0);
int tempHeight = 0;
A_Z_LIST?.forEach((value) {
tempHeight = tempHeight + widget.letterHeight;
_letterPositionList.add(tempHeight);
});
}
複製程式碼
填充每個letter widget,並設定固定寬高,程式碼如下所示
List<Widget> children = List();
A_Z_LIST.forEach((v) {
children.add(SizedBox(
width: widget.letterWidth.toDouble(),
height: widget.letterHeight.toDouble(),
child: Text(v, textAlign: TextAlign.center, style: _style),
));
});
複製程式碼
在滑動SideBar
過程中,需要檢測手勢事件,程式碼如下所示
GestureDetector(
onVerticalDragDown: (DragDownDetails details) {
//計算索引列表距離頂部的距離
if (_widgetTop == -1) {
RenderBox box = context.findRenderObject();
Offset topLeftPosition = box.localToGlobal(Offset.zero);
_widgetTop = topLeftPosition.dy.toInt();
}
//獲取touch點在索引列表的偏移值
int offset = details.globalPosition.dy.toInt() - _widgetTop;
int index = _getIndex(offset);
//判斷索引是否在列表中,如果存在,則通知上層更新資料
if (index != -1) {
_lastIndex = index;
_triggerTouchEvent(A_Z_LIST[index]);
}
},
onVerticalDragUpdate: (DragUpdateDetails details) {
//獲取touch點在索引列表的偏移值
int offset = details.globalPosition.dy.toInt() - _widgetTop;
int index = _getIndex(offset);
//並且前後兩次的是否一致,如果不一致,則通知上層更新資料
if (index != -1 && _lastIndex != index) {
_lastIndex = index;
_triggerTouchEvent(A_Z_LIST[index]);
}
},
onVerticalDragEnd: (DragEndDetails details) {
_lastIndex = -1;
_triggerTouchEvent('');
},
onTapUp: (TapUpDetails details) {
_lastIndex = -1;
_triggerTouchEvent('');
},
//填充UI
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
)
複製程式碼
上文程式碼,在onVerticalDragDown
事件時,首次獲取索引距離頂部的高度,並通過touch點的y座標獲取到touch點在偏移值,並通過該值找到目前touch的索引,記錄該狀態,並通知上層ui;在onVerticalDragUpdate
事件時,獲取索引跟onVerticalDragDown
一致,只是多了出重的操作。而onVerticalDragEnd
和onTapUp
代表touch事件的結束。到此,可以獲取到觸控SideBar時,回掉的letter
資料。
展示letter
SideBar
通常展示在ListView
的上面,父容器我們採用Stack
,如下面程式碼所示
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: Stack(
children: <Widget>[
_buildSideBar(context),
_buildLetterTips(),
],
),
);
}
Widget _buildSideBar(BuildContext context) {
return Offstage(
offstage: widget.offsetBuilder == null,
child: Align(
alignment: Alignment.centerRight,
child: SideBar(
onTouch: (letter) {
setState(() {
_letter = letter;
});
},
),
),
);
}
Widget _buildLetterTips() {
return Offstage(
offstage: TextUtil.isEmpty(_letter),
child: Align(
alignment: Alignment.center,
child: Container(
alignment: Alignment.center,
width: 65.0,
height: 65.0,
color: Color(0x40000000),
child: Text(
TextUtil.isEmpty(_letter) ? '' : _letter,
style: YZConstant.largeLargeTextWhite,
),
),
),
);
}
複製程式碼
當接收到letter
發生改變時,會通過setState
重新整理ui,當_letter
不為空時,就會展示當前letter的提示。
滾動ListView
滾動ListView目前只發現兩種方法,如下面程式碼所示
//帶動畫的滾動
scrollController.animateTo(double offset);
//不帶動畫的滾動
scrollController.jumpTo(double offset);
複製程式碼
由於上述兩種方法都需要知道滾動的具體位置,所以需要知道ListView列表的每個item相對於螢幕頂部的偏移量,所以高度必須是固定的。
獲取語言列表資料
呼叫介面github-trending-api.now.sh/languages,並封裝bean物件,如下面程式碼所示
List<TrendingLanguageBean> getTrendingLanguageBeanList(List<dynamic> list) {
List<TrendingLanguageBean> result = [];
list.forEach((item) {
result.add(TrendingLanguageBean.fromJson(item));
});
return result;
}
@JsonSerializable()
class TrendingLanguageBean extends Object {
@JsonKey(name: 'id')
String id;
@JsonKey(name: 'name')
String name;
String letter;
bool isShowLetter;
TrendingLanguageBean(this.id, this.name, {this.letter});
factory TrendingLanguageBean.fromJson(Map<String, dynamic> srcJson) =>
_$TrendingLanguageBeanFromJson(srcJson);
Map<String, dynamic> toJson() => _$TrendingLanguageBeanToJson(this);
}
複製程式碼
對獲取到的資料進行排序,如下面程式碼所示
void _sortListByLetter(List<TrendingLanguageBean> list) {
if (list == null || list.isEmpty) return;
list.sort(
(a, b) {
if (a.letter == "@" || b.letter == "#") {
return -1;
} else if (a.letter == "#" || b.letter == "@") {
return 1;
} else {
return a.letter.compareTo(b.letter);
}
},
);
}
複製程式碼
通過語言對應的首字母,設定其展示狀態,如下面程式碼所示
void _setShowLetter(List<TrendingLanguageBean> list) {
if (list != null && list.isNotEmpty) {
String tempLetter;
for (int i = 0, length = list.length; i < length; i++) {
TrendingLanguageBean bean = list[i];
String letter = bean.letter;
if (tempLetter != letter) {
tempLetter = letter;
bean.isShowLetter = true;
} else {
bean.isShowLetter = false;
}
}
}
}
複製程式碼
列表資料已經準備完畢,初始化單個item的高度,如下面程式碼所示
double getLetterHeight() => 48.0;
double getItemHeight() => 56.0;
複製程式碼
然後進一步計算每個letter在ListView
中所處的高度,如下面程式碼所示
void _initListOffset(List<TrendingLanguageBean> list) {
_letterOffsetMap.clear();
double offset = 0;
String letter;
list?.forEach((v) {
if (letter != v.letter) {
letter = v.letter;
_letterOffsetMap.putIfAbsent(letter, () => offset);
offset = offset + getLetterHeight() + getItemHeight();
} else {
offset = offset + getItemHeight();
}
});
}
複製程式碼
通過letter
獲取滾動的指定高度,如下面程式碼所示
double getOffset(String letter) => _letterOffsetMap[letter];
複製程式碼
當獲取到高度後,完成ListView
的滾動,如下面程式碼所示
if (offset != null) {
_scrollController.jumpTo(offset.clamp(
.0, _scrollController.position.maxScrollExtent));
}
複製程式碼
使用CustomPainter封裝SideBar
上文SideBar的封裝,用SizeBox
封裝每個letter,然後存放在List<Widget>
列表中,最後填充Column
,如下面程式碼所示
List<Widget> children = List();
A_Z_LIST.forEach((v) {
children.add(SizedBox(
width: widget.letterWidth.toDouble(),
height: widget.letterHeight.toDouble(),
child: Text(v, textAlign: TextAlign.center, style: _style),
));
});
child: Column(
mainAxisSize: MainAxisSize.min,
children: children,
),
複製程式碼
下面用CustomPainter
實現SideBar,如下面程式碼所示
class _SideBarPainter extends CustomPainter {
final TextStyle textStyle;
final int width;
final int height;
TextPainter _textPainter;
_SideBarPainter(this.textStyle, this.width, this.height) {
_textPainter = new TextPainter(
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
}
@override
void paint(Canvas canvas, Size size) {
int length = A_Z_LIST.length;
for (int i = 0; i < length; i++) {
_textPainter.text = new TextSpan(
text: A_Z_LIST[i],
style: textStyle,
);
_textPainter.layout();
_textPainter.paint(
canvas, Offset(width.toDouble() / 2, i * height.toDouble()));
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
複製程式碼
使用_SideBarPainter
如下面程式碼所示
child: CustomPaint(
painter: _SideBarPainter(
widget.textStyle, widget.width, widget.letterHeight),
size: Size(widget.width.toDouble(), _height),
),
複製程式碼