Build the Classic iPod UI in 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 for an 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 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.
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.
@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:
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.
// 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.
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.
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);
}