Flutter: Create a timeline UI
I like Osama's answer too but here's my quick custom implementation. It uses a CustomPainter
to draw the lines.
import 'package:flutter/material.dart';class Timeline extends StatelessWidget { const Timeline({ Key? key, required this.children, this.indicators, this.isLeftAligned = true, this.itemGap = 12.0, this.gutterSpacing = 4.0, this.padding = const EdgeInsets.all(8), this.controller, this.lineColor = Colors.grey, this.physics, this.shrinkWrap = true, this.primary = false, this.reverse = false, this.indicatorSize = 30.0, this.lineGap = 4.0, this.indicatorColor = Colors.blue, this.indicatorStyle = PaintingStyle.fill, this.strokeCap = StrokeCap.butt, this.strokeWidth = 2.0, this.style = PaintingStyle.stroke, }) : itemCount = children.length, assert(itemGap >= 0), assert(lineGap >= 0), assert(indicators == null || children.length == indicators.length), super(key: key); final List<Widget> children; final double itemGap; final double gutterSpacing; final List<Widget>? indicators; final bool isLeftAligned; final EdgeInsets padding; final ScrollController? controller; final int itemCount; final ScrollPhysics? physics; final bool shrinkWrap; final bool primary; final bool reverse; final Color lineColor; final double lineGap; final double indicatorSize; final Color indicatorColor; final PaintingStyle indicatorStyle; final StrokeCap strokeCap; final double strokeWidth; final PaintingStyle style; @override Widget build(BuildContext context) { return ListView.separated( padding: padding, separatorBuilder: (_, __) => SizedBox(height: itemGap), physics: physics, shrinkWrap: shrinkWrap, itemCount: itemCount, controller: controller, reverse: reverse, primary: primary, itemBuilder: (context, index) { final child = children[index]; final _indicators = indicators; Widget? indicator; if (_indicators != null) { indicator = _indicators[index]; } final isFirst = index == 0; final isLast = index == itemCount - 1; final timelineTile = <Widget>[ CustomPaint( foregroundPainter: _TimelinePainter( hideDefaultIndicator: indicator != null, lineColor: lineColor, indicatorColor: indicatorColor, indicatorSize: indicatorSize, indicatorStyle: indicatorStyle, isFirst: isFirst, isLast: isLast, lineGap: lineGap, strokeCap: strokeCap, strokeWidth: strokeWidth, style: style, itemGap: itemGap, ), child: SizedBox( height: double.infinity, width: indicatorSize, child: indicator, ), ), SizedBox(width: gutterSpacing), Expanded(child: child), ]; return IntrinsicHeight( child: Row( mainAxisAlignment: MainAxisAlignment.start, children: isLeftAligned ? timelineTile : timelineTile.reversed.toList(), ), ); }, ); }}class _TimelinePainter extends CustomPainter { _TimelinePainter({ required this.hideDefaultIndicator, required this.indicatorColor, required this.indicatorStyle, required this.indicatorSize, required this.lineGap, required this.strokeCap, required this.strokeWidth, required this.style, required this.lineColor, required this.isFirst, required this.isLast, required this.itemGap, }) : linePaint = Paint() ..color = lineColor ..strokeCap = strokeCap ..strokeWidth = strokeWidth ..style = style, circlePaint = Paint() ..color = indicatorColor ..style = indicatorStyle; final bool hideDefaultIndicator; final Color indicatorColor; final PaintingStyle indicatorStyle; final double indicatorSize; final double lineGap; final StrokeCap strokeCap; final double strokeWidth; final PaintingStyle style; final Color lineColor; final Paint linePaint; final Paint circlePaint; final bool isFirst; final bool isLast; final double itemGap; @override void paint(Canvas canvas, Size size) { final indicatorRadius = indicatorSize / 2; final halfItemGap = itemGap / 2; final indicatorMargin = indicatorRadius + lineGap; final top = size.topLeft(Offset(indicatorRadius, 0.0 - halfItemGap)); final centerTop = size.centerLeft( Offset(indicatorRadius, -indicatorMargin), ); final bottom = size.bottomLeft(Offset(indicatorRadius, 0.0 + halfItemGap)); final centerBottom = size.centerLeft( Offset(indicatorRadius, indicatorMargin), ); if (!isFirst) canvas.drawLine(top, centerTop, linePaint); if (!isLast) canvas.drawLine(centerBottom, bottom, linePaint); if (!hideDefaultIndicator) { final Offset offsetCenter = size.centerLeft(Offset(indicatorRadius, 0)); canvas.drawCircle(offsetCenter, indicatorRadius, circlePaint); } } @override bool shouldRepaint(CustomPainter oldDelegate) { return false; }}
You'd call it something like:
Timeline( children: <Widget>[ Container(height: 100, color: color), Container(height: 50, color: color), Container(height: 200, color: color), Container(height: 100, color: color), ], indicators: <Widget>[ Icon(Icons.access_alarm), Icon(Icons.backup), Icon(Icons.accessibility_new), Icon(Icons.access_alarm), ],),
new ListView.builder( itemBuilder: (BuildContext context, int index) { return new Stack( children: <Widget>[ new Padding( padding: const EdgeInsets.only(left: 50.0), child: new Card( margin: new EdgeInsets.all(20.0), child: new Container( width: double.infinity, height: 200.0, color: Colors.green, ), ), ), new Positioned( top: 0.0, bottom: 0.0, left: 35.0, child: new Container( height: double.infinity, width: 1.0, color: Colors.blue, ), ), new Positioned( top: 100.0, left: 15.0, child: new Container( height: 40.0, width: 40.0, decoration: new BoxDecoration( shape: BoxShape.circle, color: Colors.white, ), child: new Container( margin: new EdgeInsets.all(5.0), height: 30.0, width: 30.0, decoration: new BoxDecoration( shape: BoxShape.circle, color: Colors.red), ), ), ) ], ); }, itemCount: 5, )
For those who are scrolling here to find an easy way to implement timelines, now you can do this easily with timeline_tile.
Check out this specific delivery layout:
Or this weather timeline:
Also, the beautiful_timelines repository contains some examples built with this package.