Build the Classic iPod UI in Flutter Build a throwback iPod UI with a click wheel scroller using Flutter. 883 words. By Jeff Delaney Created Dec 1, 2019 Last Updated Dec 2, 2019 Code Slack #flutter An awesome tweet was making the rounds last week that recreates the iPod Classic UI with SwiftUI. It features a click wheel that scrolls through a list of items when rotated and makes a excellent Flutter UI challenge. Turned my iPhone into an iPod Classic with Click Wheel and Cover Flow with #SwiftUI pic.twitter.com/zVk5YJj0rh— Elvin (@elvin_not_11) November 27, 2019 Creating an animated scrolling list with Flutter is a piece of cake, but calculating scroll direction/velocity from the pan events on the wheel is a bigger challenge. The following lesson demonstrates how to build UI elements with Flutter inspired by the iPod Classic. Checkout this [rotational pan widget](/snippets/circular-drag-flutter) snippet if you're looking for a Flutter widget that is aware of clockwise rotation, like a knob or radial dial. Demo Notice how the user has three different ways to change the scroll position. Drag the PageView. Tap the >> or << icon buttons on the wheel. Pan the wheel clockwise or counterclockwise. Page View The album covers are scrolled in a PageView widget. Watch the video below for a quick introduction. Controller The control provides information about the current scroll position, as well as methods to change its position. We use a listener to react to every position change that occurs in the PageView. file_type_dartlang main.dart class IPod extends StatefulWidget { IPod({Key key}) : super(key: key); @override _IPodState createState() => _IPodState(); } class _IPodState extends State<IPod> { final PageController _pageCtrl = PageController(viewportFraction: 0.6); double currentPage = 0.0; @override void initState() { _pageCtrl.addListener(() { setState(() { currentPage = _pageCtrl.page; }); }); super.initState(); } } Horizonal Scrolling Album Covers The PageView.builder creates a view that only builds the children when they are visible, similar to virtual scrolling on the web. file_type_dartlang main.dart @override Widget build(BuildContext context) { return SafeArea( child: Column( children: <Widget>[ Container( height: 300, color: Colors.black, child: PageView.builder( controller: _pageCtrl, scrollDirection: Axis.horizontal, itemCount: 9, //Colors.accents.length, itemBuilder: (context, int currentIdx) { return AlbumCard( color: Colors.accents[currentIdx], idx: currentIdx, currentPage: currentPage, ); }, ), ), ] ) ) } 3D Transform The builder for the PageView references a custom widget named AlbumCard. It represents the UI for a single page or item in the list. The albums off center should be scaled down slightly and tilted along the y-axis. We can make that happen with a Transform widget that sets perspective on a 4x4 matrix. Learn more about transforms by watching the video below: file_type_dartlang main.dart class AlbumCard extends StatelessWidget { final Color color; final int idx; final double currentPage; AlbumCard({this.color, this.idx, this.currentPage}); @override Widget build(BuildContext context) { double relativePosition = idx - currentPage; return Container( width: 250, child: Transform( transform: Matrix4.identity() ..setEntry(3, 2, 0.003) // add perspective ..scale((1 - relativePosition.abs()).clamp(0.2, 0.6) + 0.4) ..rotateY(relativePosition), // ..rotateZ(relativePosition), alignment: relativePosition >= 0 ? Alignment.centerLeft : Alignment.centerRight, child: Container( margin: EdgeInsets.only(top: 20, bottom: 20, left: 5, right: 5), padding: EdgeInsets.all(10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: color, image: DecorationImage( fit: BoxFit.cover, image: NetworkImage(images[idx]), ), ), ), ), ); } } Click Wheel The most difficult aspect of this demo is building a circular shape that controls the PageView. While the user drags clockwise it should scroll the view to the right, or to the left when dragged counterclockwise. It must also have buttons that can animate between individual items. Doughnut-shaped Gesture Detector The GestureDetector should only fire events when the outer ring or doughnut is panned across. Placing the widgets in a Stack allow a smaller circle to be placed on top, which blocks the detection of events in this areas. file_type_dartlang main.dart // Add this to the Column list Center( child: Stack( alignment: Alignment.center, children: [ GestureDetector( onPanUpdate: _panHandler, // Not implemented yet, see next steps child: Container( height: 300, width: 300, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.black, ), ), ), Container( height: 100, width: 100, decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.white38, ), ), ]), ), Add Buttons to the Wheel The code below represents a single click button in the wheel. Reference the full source code to see them all. Notice how it uses the PageController to animate to a new position on a button press event. file_type_dartlang main.dart Container( child: IconButton( icon: Icon(Icons.fast_forward), iconSize: 40, onPressed: () => _pageCtrl.animateToPage( (_pageCtrl.page + 1).toInt(), duration: Duration(milliseconds: 300), curve: Curves.easeIn ), ), alignment: Alignment.centerRight, margin: EdgeInsets.only(right: 30), ), Handle Rotational Pan Movement Building a rotation-aware widget requires some calculations. Checkout the draggable rotating wheel in Flutter snippet for a more detailed explanation of these calculations. Basically, this gives us a way to detect clockwise or counter-clockwise movement. file_type_dartlang main.dart void _panHandler(DragUpdateDetails d) { double radius = 150; /// Pan location on the wheel bool onTop = d.localPosition.dy <= radius; bool onLeftSide = d.localPosition.dx <= radius; bool onRightSide = !onLeftSide; bool onBottom = !onTop; /// Pan movements bool panUp = d.delta.dy <= 0.0; bool panLeft = d.delta.dx <= 0.0; bool panRight = !panLeft; bool panDown = !panUp; /// Absoulte change on axis double yChange = d.delta.dy.abs(); double xChange = d.delta.dx.abs(); /// Directional change on wheel double verticalRotation = (onRightSide && panDown) || (onLeftSide && panUp) ? yChange : yChange * -1; double horizontalRotation = (onTop && panRight) || (onBottom && panLeft) ? xChange : xChange * -1; // Total computed change double rotationalChange = (verticalRotation + horizontalRotation) * (d.delta.distance * 0.2); // Move the page view scroller _pageCtrl.jumpTo(_pageCtrl.offset + rotationalChange); }