-
-
Save peerwaya/41a187edfde4ff8b00af74d741a16df5 to your computer and use it in GitHub Desktop.
Example Flutter app showing iOS jank on first launch with full-screen page flip animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Related: https://github.com/flutter/flutter/issues/76180 | |
import 'package:flutter/foundation.dart'; | |
import 'package:flutter/material.dart'; | |
import 'dart:math'; | |
void main() { | |
runApp(MyApp()); | |
} | |
class MyApp extends StatelessWidget { | |
final pageFlipKey = GlobalKey<PageFlipBuilderState>(); | |
@override | |
Widget build(BuildContext context) { | |
return MaterialApp( | |
debugShowCheckedModeBanner: false, | |
home: PageFlipBuilder( | |
key: pageFlipKey, | |
frontBuilder: (_) => LightHomePage( | |
onFlip: () => pageFlipKey.currentState?.flip(), | |
), | |
backBuilder: (_) => DarkHomePage( | |
onFlip: () => pageFlipKey.currentState?.flip(), | |
), | |
maxTilt: 0.003, | |
maxScale: 0.2, | |
), | |
); | |
} | |
} | |
class LightHomePage extends StatelessWidget { | |
const LightHomePage({Key? key, this.onFlip}) : super(key: key); | |
final VoidCallback? onFlip; | |
@override | |
Widget build(BuildContext context) { | |
return Theme( | |
data: ThemeData( | |
brightness: Brightness.light, | |
textTheme: TextTheme( | |
headline3: Theme.of(context) | |
.textTheme | |
.headline3! | |
.copyWith(color: Colors.black87, fontWeight: FontWeight.w600), | |
)), | |
child: Scaffold( | |
body: Container( | |
padding: const EdgeInsets.all(24.0), | |
child: Column( | |
children: [ | |
const ProfileHeader(prompt: 'Hello,\nsunshine!'), | |
const Spacer(), | |
BottomFlipIconButton(onFlip: onFlip), | |
], | |
), | |
), | |
), | |
); | |
} | |
} | |
class DarkHomePage extends StatelessWidget { | |
const DarkHomePage({Key? key, this.onFlip}) : super(key: key); | |
final VoidCallback? onFlip; | |
@override | |
Widget build(BuildContext context) { | |
return Theme( | |
data: ThemeData( | |
brightness: Brightness.dark, | |
textTheme: TextTheme( | |
headline3: Theme.of(context) | |
.textTheme | |
.headline3! | |
.copyWith(color: Colors.white, fontWeight: FontWeight.w600), | |
)), | |
child: Scaffold( | |
body: Container( | |
padding: const EdgeInsets.all(24.0), | |
child: Column( | |
children: [ | |
const ProfileHeader(prompt: 'Good night,\nsleep tight!'), | |
const Spacer(), | |
BottomFlipIconButton(onFlip: onFlip), | |
], | |
), | |
), | |
), | |
); | |
} | |
} | |
class OptionsDrawer extends StatelessWidget { | |
@override | |
Widget build(BuildContext context) { | |
return Drawer(); | |
} | |
} | |
class ProfileHeader extends StatelessWidget { | |
const ProfileHeader({Key? key, required this.prompt}) : super(key: key); | |
final String prompt; | |
@override | |
Widget build(BuildContext context) { | |
return SafeArea( | |
bottom: false, | |
child: Text(prompt, style: Theme.of(context).textTheme.headline3), | |
); | |
} | |
} | |
class BottomFlipIconButton extends StatelessWidget { | |
const BottomFlipIconButton({Key? key, this.onFlip}) : super(key: key); | |
final VoidCallback? onFlip; | |
@override | |
Widget build(BuildContext context) { | |
return Row( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: [ | |
IconButton( | |
onPressed: onFlip, | |
icon: const Icon(Icons.flip), | |
) | |
], | |
); | |
} | |
} | |
class PageFlipBuilder extends StatefulWidget { | |
const PageFlipBuilder({ | |
Key? key, | |
required this.frontBuilder, | |
required this.backBuilder, | |
this.nonInteractiveAnimationDuration = const Duration(milliseconds: 500), | |
this.interactiveFlipEnabled = true, | |
this.flipAxis = Axis.horizontal, | |
this.maxTilt = 0.003, | |
this.maxScale = 0.2, | |
}) : super(key: key); | |
final WidgetBuilder frontBuilder; | |
final WidgetBuilder backBuilder; | |
final Duration nonInteractiveAnimationDuration; | |
final bool interactiveFlipEnabled; | |
final Axis flipAxis; | |
final double maxTilt; | |
final double maxScale; | |
@override | |
PageFlipBuilderState createState() => PageFlipBuilderState(); | |
} | |
class PageFlipBuilderState extends State<PageFlipBuilder> | |
with SingleTickerProviderStateMixin { | |
bool _showFrontSide = true; | |
late final AnimationController _controller; | |
/// Starts a page flip. | |
/// | |
/// Example: | |
/// ```dart | |
/// PageFlipBuilder( | |
/// key: pageFlipKey, | |
/// frontBuilder: (_) => Screen1( | |
/// onFlip: () => pageFlipKey.currentState?.flip(), | |
/// ), | |
/// backBuilder: (_) => Screen2( | |
/// onFlip: () => pageFlipKey.currentState?.flip(), | |
/// ), | |
/// ); | |
/// ``` | |
void flip() { | |
if (_showFrontSide) { | |
_controller.forward(); | |
} else { | |
_controller.reverse(); | |
} | |
} | |
void _handleDragUpdate(DragUpdateDetails details, double crossAxisLength) { | |
print(crossAxisLength); | |
_controller.value += details.primaryDelta! / crossAxisLength; | |
} | |
void _handleDragEnd(DragEndDetails details, double crossAxisLength) { | |
if (_controller.isAnimating || | |
_controller.status == AnimationStatus.completed || | |
_controller.status == AnimationStatus.dismissed) return; | |
const velocityThreshold = 2.0; | |
final velocity = widget.flipAxis == Axis.horizontal | |
? details.velocity.pixelsPerSecond.dx | |
: details.velocity.pixelsPerSecond.dy; | |
final flingVelocity = velocity / crossAxisLength; | |
// if value and velocity are 0, the gesture was a tap so we return early | |
if (_controller.value == 0.0 && flingVelocity == 0.0) { | |
return; | |
} | |
if (_controller.value > 0.5 || | |
_controller.value > 0.0 && flingVelocity > velocityThreshold) { | |
_controller.fling(velocity: velocityThreshold); | |
} else if (_controller.value < -0.5 || | |
_controller.value < 0.0 && flingVelocity < -velocityThreshold) { | |
_controller.fling(velocity: -velocityThreshold); | |
} else if (_controller.value > 0.0 || | |
_controller.value > 0.5 && flingVelocity < -velocityThreshold) { | |
// controller can't fling to 0.0 because the lowerBound is -1.0 | |
// so we decrement the value by 1.0 and toggle the state to get the same effect | |
_controller.value -= 1.0; | |
setState(() => _showFrontSide = !_showFrontSide); | |
_controller.fling(velocity: -velocityThreshold); | |
} else if (_controller.value > -0.5 || | |
_controller.value < -0.5 && flingVelocity > velocityThreshold) { | |
// controller can't fling to 0.0 because the upperBound is 1.0 | |
// so we increment the value by 1.0 and toggle the state to get the same effect | |
_controller.value += 1.0; | |
setState(() => _showFrontSide = !_showFrontSide); | |
_controller.fling(velocity: velocityThreshold); | |
} | |
} | |
@override | |
void initState() { | |
_controller = AnimationController( | |
vsync: this, | |
duration: widget.nonInteractiveAnimationDuration, | |
// lowerBound of -1.0 is needed for the back flip | |
lowerBound: -1.0, | |
// upperBound of 1.0 is needed for the front flip | |
upperBound: 1.0, | |
); | |
_controller.value = 0.0; | |
_controller.addStatusListener(_updateStatus); | |
super.initState(); | |
} | |
@override | |
void dispose() { | |
_controller.removeStatusListener(_updateStatus); | |
_controller.dispose(); | |
super.dispose(); | |
} | |
void _updateStatus(AnimationStatus status) { | |
if (status == AnimationStatus.completed || | |
status == AnimationStatus.dismissed) { | |
// The controller always completes a forward animation with value 1.0 | |
// and a reverse animation with a value of -1.0. | |
// By resetting the value to 0.0 and toggling the state | |
// we are preparing the controller for the next animation | |
// while preserving the widget appearance on screen. | |
_controller.value = 0.0; | |
setState(() => _showFrontSide = !_showFrontSide); | |
} | |
} | |
@override | |
Widget build(BuildContext context) { | |
final isHorizontal = widget.flipAxis == Axis.horizontal; | |
return LayoutBuilder(builder: (_, constraints) { | |
final crossAxisLength = | |
isHorizontal ? constraints.maxWidth : constraints.maxHeight; | |
return GestureDetector( | |
onHorizontalDragUpdate: widget.interactiveFlipEnabled && isHorizontal | |
? (details) => _handleDragUpdate(details, crossAxisLength) | |
: null, | |
onHorizontalDragEnd: widget.interactiveFlipEnabled && isHorizontal | |
? (details) => _handleDragEnd(details, crossAxisLength) | |
: null, | |
onVerticalDragUpdate: widget.interactiveFlipEnabled && !isHorizontal | |
? (details) => _handleDragUpdate(details, crossAxisLength) | |
: null, | |
onVerticalDragEnd: widget.interactiveFlipEnabled && !isHorizontal | |
? (details) => _handleDragEnd(details, crossAxisLength) | |
: null, | |
child: AnimatedPageFlipBuilder( | |
animation: _controller, | |
frontBuilder: widget.frontBuilder, | |
backBuilder: widget.backBuilder, | |
showFrontSide: _showFrontSide, | |
flipAxis: widget.flipAxis, | |
maxTilt: widget.maxTilt, | |
maxScale: widget.maxScale), | |
); | |
}); | |
} | |
} | |
class AnimatedPageFlipBuilder extends StatelessWidget { | |
const AnimatedPageFlipBuilder({ | |
Key? key, | |
required this.animation, | |
required this.showFrontSide, | |
required this.frontBuilder, | |
required this.backBuilder, | |
this.flipAxis = Axis.horizontal, | |
this.maxTilt = 0.003, | |
this.maxScale = 0.2, | |
}) : super(key: key); | |
final Animation<double> animation; | |
final bool showFrontSide; | |
final WidgetBuilder frontBuilder; | |
final WidgetBuilder backBuilder; | |
final Axis flipAxis; | |
final double maxTilt; | |
final double maxScale; | |
bool get _isAnimationFirstHalf => animation.value.abs() < 0.5; | |
double _getTilt() { | |
var tilt = (animation.value - 0.5).abs() - 0.5; | |
if (animation.value < -0.5) { | |
tilt = 1.0 + animation.value; | |
} | |
return tilt * (_isAnimationFirstHalf ? -maxTilt : maxTilt); | |
} | |
double _rotationAngle() { | |
final rotationValue = animation.value * pi; | |
if (animation.value > 0.5) { | |
return pi - rotationValue; // input from 0.5 to 1.0 | |
} else if (animation.value > -0.5) { | |
return rotationValue; // input from -0.5 to 0.5 | |
} else { | |
return -pi - rotationValue; // input from -1.0 to -0.5 | |
} | |
} | |
double _scale() { | |
final absValue = animation.value.abs(); | |
return 1.0 - (absValue < 0.5 ? absValue : 1.0 - absValue) * maxScale; | |
} | |
@override | |
Widget build(BuildContext context) { | |
return AnimatedBuilder( | |
animation: animation, | |
builder: (context, _) { | |
final child = _isAnimationFirstHalf ^ showFrontSide | |
? backBuilder(context) | |
: frontBuilder(context); | |
final matrix = flipAxis == Axis.horizontal | |
? (Matrix4.rotationY(_rotationAngle())..setEntry(3, 0, _getTilt())) | |
: (Matrix4.rotationX(_rotationAngle())..setEntry(3, 1, _getTilt())); | |
final scale = _scale(); | |
return Transform( | |
transform: matrix * Matrix4.diagonal3Values(scale, scale, 1.0), | |
child: child, | |
alignment: Alignment.center, | |
); | |
}, | |
); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment