Retro rotary passcode input UI challenge made with Flutter. The main focus of this part is motion design - animations.
In the previous part, we implemented all the static elements of the rotary passcode UI. In this article, I will cover the motion design part of the challenge - animations, gestures, transitions and other fancy eye candies visible on the screen.
If you prefer video content, check out the video version of this article on YouTube.
Animations overview
In Flutter, there are two types of code-based animations - implicit and explicit ones. For implicit animations, just select and use one of the pre-defined animation widgets from the Flutter library.
For explicit animations, you need to create an animation controller and take care of all the implementation details yourself. This is a more flexible approach, but it requires more code (possibly, more bugs as well).
To learn more about animations in Flutter, check out the introduction to animations.
Input mode button animation
Let’s start by implementing the implicit cross-fade animation for the input mode button.
If you want to follow along, the code where we left off in the previous part is available on GitHub.
First, add the animationDuration
property that will allow us to synchronise this animation with other components.
import 'package:flutter/material.dart';
class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.animationDuration,
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});
final Duration animationDuration;
final bool simpleInputMode;
final VoidCallback onModeChanged;
Widget build(BuildContext context) {
return GestureDetector(
onTap: onModeChanged,
child: Text(
(simpleInputMode ? 'Original' : 'Simplify').toUpperCase(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.black, fontWeight: FontWeight.bold),
),
);
}
}
Then, define the animation duration by creating an _animationDuration
property and passing it to the InputModeButton
.
import 'package:flutter/material.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});
final String expectedCode;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;
var _simpleInputMode = false;
void initState() {
super.initState();
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
}
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? const PasscodeInput()
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
To make life easier, create a private _Button
widget that accepts the label
property and the onTap
callback and move the generic button’s code to the private widget.
import 'package:flutter/material.dart';
class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.animationDuration,
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});
final Duration animationDuration;
final bool simpleInputMode;
final VoidCallback onModeChanged;
Widget build(BuildContext context) {
return GestureDetector(
onTap: onModeChanged,
child: Text(
(simpleInputMode ? 'Original' : 'Simplify').toUpperCase(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.black, fontWeight: FontWeight.bold),
),
);
}
}
class _Button extends StatelessWidget {
const _Button({
required this.label,
required this.onTap,
});
final String label;
final VoidCallback onTap;
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Text(
label.toUpperCase(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.black, fontWeight: FontWeight.bold),
),
);
}
}
The easiest way to implement the animation is by using the AnimatedCrossFade
widget. Add it to the build method, pass the animation duration and different button values for different input modes. Now you should see why extracting the generic button code to a separate widget was a good idea.
For the crossFadeState
, use the simpleInputMode
value to decide which button variation to show. Lastly, set the alignment to centerLeft
and use easeInOutCubic
animation curves for a slow starting as well as slowly ending animation.
import 'package:flutter/material.dart';
class InputModeButton extends StatelessWidget {
const InputModeButton({
required this.animationDuration,
required this.simpleInputMode,
required this.onModeChanged,
super.key,
});
final Duration animationDuration;
final bool simpleInputMode;
final VoidCallback onModeChanged;
Widget build(BuildContext context) {
return AnimatedCrossFade(
alignment: Alignment.centerLeft,
firstCurve: Curves.easeInOutCubic,
secondCurve: Curves.easeInOutCubic,
firstChild: _Button(label: 'Original', onTap: onModeChanged),
secondChild: _Button(label: 'Simplify', onTap: onModeChanged),
crossFadeState: simpleInputMode
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: animationDuration,
);
}
}
class _Button extends StatelessWidget {
const _Button({
required this.label,
required this.onTap,
});
final String label;
final VoidCallback onTap;
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Text(
label.toUpperCase(),
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(color: Colors.black, fontWeight: FontWeight.bold),
),
);
}
}
That’s it for the button animation. The AnimatedCrossFade
widget does all the dirty work.
Passcode digits indicator animations
The next UI element we will cover is the passcode digits indicator. The indicator consists of several different animations:
- Scale animation on digit input;
- Success animation on successful passcode input;
- Error animation sequence when the passcode is incorrect;
- Digits indicator size transition when switching input modes.
However, before implementing anything that animates, we need to make the input buttons interactive and provide callbacks to them.
Making the input buttons work
Add the onDigitSelected
property to the PasscodeInput
widget. Then, add the render method for the DialNumber
widget so that we can wrap it with a GestureDetector
.
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../dial_number.dart';
const _alignment = MainAxisAlignment.spaceEvenly;
class PasscodeInput extends StatelessWidget {
const PasscodeInput({
required this.onDigitSelected,
super.key,
});
final ValueSetter<int> onDigitSelected;
Widget _renderDialNumber(int index) => GestureDetector(
onTap: () => onDigitSelected(index),
child: DialNumber(Constants.inputValues[index]),
);
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: _alignment,
children: [
for (var i = 0; i < 3; i++)
Row(
mainAxisAlignment: _alignment,
children: [
for (var j = 0; j < 3; j++) _renderDialNumber(i * 3 + j),
],
),
_renderDialNumber(9),
],
);
}
}
When you think about it, the _renderDialNumber
could actually be a separate widget rather than a method (and it probably should be). However, in this case, it’s a simple enough method to keep it as is.
Don't cancel me for this. Please. 🫠
Next, add the copyWith
method to the PasscodeDigit
model.
import 'package:flutter/material.dart';
import '../../utils.dart';
const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;
class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});
final Color backgroundColor;
final Color fontColor;
final int? value;
PasscodeDigit copyWith({
Color? backgroundColor,
Color? fontColor,
int? value,
}) =>
PasscodeDigit(
backgroundColor: backgroundColor ?? this.backgroundColor,
fontColor: fontColor ?? this.fontColor,
value: value ?? this.value,
);
}
class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});
final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;
Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (var i = 0; i < passcodeDigitValues.length; i++)
_PasscodeDigitContainer(
backgroundColor: passcodeDigitValues[i].backgroundColor,
fontColor: passcodeDigitValues[i].fontColor,
digit: passcodeDigitValues[i].value,
size: simpleInputMode
? _passcodeDigitSizeBig
: _passcodeDigitSizeSmall,
),
].addBetween(
SizedBox(
width:
simpleInputMode ? _passcodeDigitGapBig : _passcodeDigitGapSmall,
),
),
),
);
}
}
class _PasscodeDigitContainer extends StatelessWidget {
const _PasscodeDigitContainer({
required this.backgroundColor,
required this.fontColor,
required this.digit,
required this.size,
});
final Color backgroundColor;
final Color fontColor;
final int? digit;
final double size;
Widget build(BuildContext context) {
final digitContainerSize = size - _passcodeDigitPadding;
final containerSize = digit != null ? digitContainerSize : 0.0;
return Container(
height: size,
width: size,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Container(
height: containerSize,
width: containerSize,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: digit != null
? Center(
child: Text(
'$digit',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: fontColor,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
)
: null,
),
);
}
}
Then, inside the PasscodeInputView
state class, create a _currentInputIndex
variable and onDigitSelected
method. The method gets the current active passcode digit value and updates it using the copyWith
method as well as refreshes the UI. Also, do not forget to pass the method as a callback to the PasscodeInput
widget.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
super.key,
});
final String expectedCode;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
void initState() {
super.initState();
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
}
void _onDigitSelected(int index) {
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
}
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
The buttons are working now. However, the passcode digits indicator still lacks animations. And… well… we probably need to add some validation code first.
Passcode validation logic
What I mean by validation is that once we input all the required digits, we should either see a success or error indicator (animations #2 and #3 from the list above). Also, the input must be reset so we can try again.
In PasscodeInputView
, add onSuccess
and onError
callbacks...
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late final List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
void initState() {
super.initState();
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
}
void _onDigitSelected(int index) {
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
}
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
… and pass them from the root of the application. We will leave them empty, but in real-world use cases, you should probably trigger the route change or handle any other actions here.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_fonts/google_fonts.dart';
import 'passcode_input_view.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
runApp(const _App());
}
class _App extends StatelessWidget {
const _App();
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Rotary Passcode',
theme: Theme.of(context).copyWith(
textTheme: GoogleFonts.kanitTextTheme(),
),
home: PasscodeInputView(
expectedCode: '6942',
onSuccess: () {
// Handle valid passcode here
},
onError: () {
// Handle invalid passcode here
},
),
);
}
}
Then, add the _resetDigits
method and refactor the code a bit by moving the initialisation code from the initState
method. Also, do not forget to reset the current active passcode digit index.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Next, add the _validatePasscode
method and call it from the _onDigitSelected
callback. The validation must be run only when the last digit in the passcode is inserted. If that’s true, we get a string representation of our input and compare it to the expected code. If the code is correct, we trigger the onSuccess
callback, onError
otherwise. Also, do not forget to reset the digits afterwards.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _validatePasscode() {
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
_resetDigits();
}
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
The passcode input should be working without any ugly exceptions now. Let’s get back to the fun part - animations.
Passcode digit input animation
To animate the digit input, add the animationDuration
property to the PasscodeDigits
widget and pass it down to the _PasscodeDigitContainer
.
import 'package:flutter/material.dart';
import '../../utils.dart';
const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;
class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});
final Color backgroundColor;
final Color fontColor;
final int? value;
PasscodeDigit copyWith({
Color? backgroundColor,
Color? fontColor,
int? value,
}) =>
PasscodeDigit(
backgroundColor: backgroundColor ?? this.backgroundColor,
fontColor: fontColor ?? this.fontColor,
value: value ?? this.value,
);
}
class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.animationDuration,
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});
final Duration animationDuration;
final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;
Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (var i = 0; i < passcodeDigitValues.length; i++)
_PasscodeDigitContainer(
animationDuration: animationDuration,
backgroundColor: passcodeDigitValues[i].backgroundColor,
fontColor: passcodeDigitValues[i].fontColor,
digit: passcodeDigitValues[i].value,
size: simpleInputMode
? _passcodeDigitSizeBig
: _passcodeDigitSizeSmall,
),
].addBetween(
SizedBox(
width:
simpleInputMode ? _passcodeDigitGapBig : _passcodeDigitGapSmall,
),
),
),
);
}
}
class _PasscodeDigitContainer extends StatelessWidget {
const _PasscodeDigitContainer({
required this.animationDuration,
required this.backgroundColor,
required this.fontColor,
required this.digit,
required this.size,
});
final Duration animationDuration;
final Color backgroundColor;
final Color fontColor;
final int? digit;
final double size;
Widget build(BuildContext context) {
final digitContainerSize = size - _passcodeDigitPadding;
final containerSize = digit != null ? digitContainerSize : 0.0;
return Container(
height: size,
width: size,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Container(
height: containerSize,
width: containerSize,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: digit != null
? Center(
child: Text(
'$digit',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: fontColor,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
)
: null,
),
);
}
}
This is another implicit animation - all we need to do is replace the Container
with AnimatedContainer
and set the duration
property with a specific animation curve.
import 'package:flutter/material.dart';
import '../../utils.dart';
const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;
class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});
final Color backgroundColor;
final Color fontColor;
final int? value;
PasscodeDigit copyWith({
Color? backgroundColor,
Color? fontColor,
int? value,
}) =>
PasscodeDigit(
backgroundColor: backgroundColor ?? this.backgroundColor,
fontColor: fontColor ?? this.fontColor,
value: value ?? this.value,
);
}
class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.animationDuration,
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});
final Duration animationDuration;
final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;
Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (var i = 0; i < passcodeDigitValues.length; i++)
_PasscodeDigitContainer(
animationDuration: animationDuration,
backgroundColor: passcodeDigitValues[i].backgroundColor,
fontColor: passcodeDigitValues[i].fontColor,
digit: passcodeDigitValues[i].value,
size: simpleInputMode
? _passcodeDigitSizeBig
: _passcodeDigitSizeSmall,
),
].addBetween(
SizedBox(
width:
simpleInputMode ? _passcodeDigitGapBig : _passcodeDigitGapSmall,
),
),
),
);
}
}
class _PasscodeDigitContainer extends StatelessWidget {
const _PasscodeDigitContainer({
required this.animationDuration,
required this.backgroundColor,
required this.fontColor,
required this.digit,
required this.size,
});
final Duration animationDuration;
final Color backgroundColor;
final Color fontColor;
final int? digit;
final double size;
Widget build(BuildContext context) {
final digitContainerSize = size - _passcodeDigitPadding;
final containerSize = digit != null ? digitContainerSize : 0.0;
return Container(
height: size,
width: size,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: AnimatedContainer(
height: containerSize,
width: containerSize,
duration: animationDuration,
curve: Curves.easeInOut,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: digit != null
? Center(
child: Text(
'$digit',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: fontColor,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
)
: null,
),
);
}
}
The last thing to do here is to pass the animation duration to the PasscodeDigits
widget.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _validatePasscode() {
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
_resetDigits();
}
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Now, when you input a digit, you should see a smooth scale animation. The passcode validation is still missing the success and error animations. Let’s add them next.
Passcode validation animations
First, add a boolean flag to track whether the passcode animation is currently in progress or not. If the animation is running, disable all passcode inputs and validation. For that, also add a handy method to toggle the animation status.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _validatePasscode() {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
_resetDigits();
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Next, add a method that updates the passcode digit colours. In the method, loop over each digit with a delay. This helps you achieve the staggered animation effect. After the delay, simply update the background and font colours of a passcode digit.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _validatePasscode() {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
_resetDigits();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Before changing colours, calculate the staggered animation interval.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
void _validatePasscode() {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
_resetDigits();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
In this case, we split the animation into equal intervals. It means that each subsequent passcode digit’s animation is run after the same delay, thus providing a smooth reveal effect.
Before and after the code validation portion, call _togglePasscodeAnimation
for the animation progress flag to return the correct value. Also, update the validation method to return Future
and add a little delay before resetting the digits so that the success or error state is visible on the screen for a bit.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
widget.onSuccess();
} else {
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
For the success state, it’s pretty simple. Just update the background colour to green and make the font colour transparent.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
The error case is a bit more tricky. First, all the input digits are revealed on the red background. Then, they are reset to white circles and disappear afterwards.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: const RotaryDialInput(),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
The whole animation sequence is a little bit longer than the previous one, but it provides the context to the user of what went wrong.
Rotary dial animation
Next on the list is a giga Chad, the main boss of this tutorial - the rotary dial animation. I hope you remember some maths, kids, because we’re going to need it.
Dial offset calculation
The first thing you should do is convert the RotaryDialInput
widget to a stateful one since we will use explicit animations there. Also, add the required callbacks.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
),
),
],
);
},
);
}
}
The compiler is screaming in errors at you now, so just pass the callbacks to the RotaryDialInput
widget.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: RotaryDialInput(
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Add two offset variables. The _currentDragOffset
is needed to track the current drag position of your finger while the _startAngleOffset
stores the value of how much the foreground part changed from its starting position in radians.
Yup, we are getting into this kind of calculation now.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
),
),
],
);
},
);
}
}
The dial’s foreground should rotate around the circle. For that, we only need to keep track of a starting position of an arc, other values could be recalculated based on that. Thus, we add a startAngleOffset
property and extend the shouldRepaint
method so that the foreground will be updated once the property changes.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
required this.startAngleOffset,
});
final double numberRadiusFromCenter;
final double startAngleOffset;
void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;
final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;
canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
Constants.firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);
for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);
canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
canvas.drawCircle(
size.centerOffset +
Offset.fromDirection(math.pi / 6, numberRadiusFromCenter),
ringWidth / 6,
Paint()..color = const Color.fromRGBO(255, 255, 255, 1.0),
);
canvas.restore();
}
bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter &&
oldDelegate.startAngleOffset != startAngleOffset;
}
Again, compiler errors, just pass the _startAngleOffset
property to the painter.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
],
);
},
);
}
}
First, we create an angleOffset
variable and calculate its value in radians. Then, we add this offset to the firstDialNumberPosition
so that the arc and the dial number “holes” are painted in their rotated positions.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
required this.startAngleOffset,
});
final double numberRadiusFromCenter;
final double startAngleOffset;
void paint(Canvas canvas, Size size) {
const firstDialNumberPosition = Constants.firstDialNumberPosition;
const ringWidth = Constants.rotaryRingWidth;
final angleOffset = startAngleOffset * firstDialNumberPosition;
final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;
canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
angleOffset + firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);
for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
angleOffset + math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);
canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
canvas.drawCircle(
size.centerOffset +
Offset.fromDirection(math.pi / 6, numberRadiusFromCenter),
ringWidth / 6,
Paint()..color = const Color.fromRGBO(255, 255, 255, 1.0),
);
canvas.restore();
}
bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter &&
oldDelegate.startAngleOffset != startAngleOffset;
}
Dial rotation
Next, add a method to rotate the dial to its initial position. Meaning, set the start angle offset to zero.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
],
);
},
);
}
}
Add drag gesture methods that will be used to track the pointer (or finger) movement on the dial. Then, wrap the foreground painter with the GestureDetector
widget and pass the previously created methods.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {}
void _onPanEnd(DragEndDetails details) {}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
The _onPanStart
method implementation is as simple as calculating the initial touch position offset from the dial input centre.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {}
void _onPanEnd(DragEndDetails details) {}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
The _onPanUpdate
is a bit more tricky. First, calculate the current offset by adding the delta value to the previous offset. Then, compare the current and previous drag directions. If the product of these two values is negative, it means that the drag direction has changed and the update should be ignored. Calculate a new angle offset value. If the value is out of bounds, ignore the update. Finally, update the start angle offset with a new value.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
For the _onPanEnd
method, we normalise the offset value so that it actually shows the offset from the first dial number. If the offset value is too minor, we simply reset the dial. Then, we convert the offset angle value to the dial number index. The dial numbers are positioned 30 degrees apart around the circle. Thus, we convert radians to degrees and divide the value by 30. After applying some rounding, we get the selected dial number index. Finally, we create the _addDigit
method that calls the onDigitSelected
callback and resets the dial to its initial position.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart();
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
The rotary dial is finally working and you can already use it to enter the passcode! However, currently, there is no animation to gradually return the dial to its initial position - it jumps straight to it. Let’s make it smoother.
Smooth dial reset
First, add an animationDuration
property...
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput> {
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart();
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
… and pass it from the PasscodeInputView
.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
_validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(onDigitSelected: _onDigitSelected)
: RotaryDialInput(
animationDuration: _animationDuration,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Since we will work with explicit animations here, add the SingleTickerProviderStateMixin
to the state class. Create an animation controller for the dial and update the _isAnimating
getter. Also, add guards to skip all the drag gesture callback methods when the animation is running.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating => _dialController.isAnimating;
void _rotateDialToStart() {
setState(() {
_startAngleOffset = 0.0;
});
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart();
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
Create the _rotationAnimation
variable that will smoothly reset the dial to its initial position. Initialise the dialController
and make sure that the startAngleOffset
value gets updated when the animation runs. Also, update the dial reset method. Instead of setting the value to 0.0 straight away, a Tween animation is created that starts from the current dial start angle offset and ends at 0 - its initial value.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating => _dialController.isAnimating;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart();
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
The important part here is that we need to validate the code explicitly. In the animation, the passcode gets validated only after the dial gets back to its initial position. Thus, we run the rotation animation and call the passcode validation callback afterwards.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating => _dialController.isAnimating;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart().then((_) async => await widget.onValidatePasscode());
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
Also, because of the delay, we should avoid validating the passcode just after inserting a digit. Thus, add the autovalidate
flag to the _onDigitSelected
method (false by default) and update the onDigitSelected
callback for the PasscodeInput
widget.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView> {
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating => _passcodeAnimationInProgress;
void initState() {
super.initState();
_resetDigits();
}
void _onDigitSelected(int index, {bool autovalidate = false}) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
if (autovalidate) _validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(
onDigitSelected: (index) => _onDigitSelected(
index,
autovalidate: true,
),
)
: RotaryDialInput(
animationDuration: _animationDuration,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
That’s it! The rotary dial animation should work smoothly now. The passcode gets validated at the right time as well.
Input mode change animation
The last remaining animation that we need to implement is the transition between different input modes. A little bit tricky, but a really fun one! The input mode change animation consists of 4 parts:
- Hiding the rotary dial foreground;
- Hiding the rotary dial background;
- Hero animation of the passcode digits component changing its position;
- Hero animation of dial numbers transitioning to their new positions.
Hiding the rotary dial foreground
First, add the SingleTickerProviderStateMixin
to the state class, create a mode change animation controller and update the _isAnimating
getter. Then, initialise the mode change controller that runs twice as long as the animation duration. It’s because one passcode input view should disappear first and then the other one appear on the screen.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView>
with SingleTickerProviderStateMixin {
late final AnimationController _modeChangeController;
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating =>
_modeChangeController.isAnimating || _passcodeAnimationInProgress;
void initState() {
super.initState();
_modeChangeController = AnimationController(
duration: _animationDuration * 2,
vsync: this,
)..addListener(() => setState(() {}));
_resetDigits();
}
void dispose() {
_modeChangeController.dispose();
super.dispose();
}
void _onDigitSelected(int index, {bool autovalidate = false}) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
if (autovalidate) _validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() => setState(() => _simpleInputMode = !_simpleInputMode);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(
onDigitSelected: (index) => _onDigitSelected(
index,
autovalidate: true,
),
)
: RotaryDialInput(
animationDuration: _animationDuration,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Refactor the onModeChanged
callback by extracting the code of the input mode update and adding some animation code. If the animation is completed, we need to reverse it and run it forward otherwise. The delay here is needed since we will run a hero animation for dial numbers first before starting the rotary dial animation part. You will notice this a little bit later.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView>
with SingleTickerProviderStateMixin {
late final AnimationController _modeChangeController;
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating =>
_modeChangeController.isAnimating || _passcodeAnimationInProgress;
void initState() {
super.initState();
_modeChangeController = AnimationController(
duration: _animationDuration * 2,
vsync: this,
)..addListener(() => setState(() {}));
_resetDigits();
}
void dispose() {
_modeChangeController.dispose();
super.dispose();
}
void _onDigitSelected(int index, {bool autovalidate = false}) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
if (autovalidate) _validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() {
if (_modeChangeController.isCompleted) {
_changeInputMode();
Future.delayed(_animationDuration, () => _modeChangeController.reverse());
} else {
_modeChangeController.forward().then((_) => _changeInputMode());
}
}
void _changeInputMode() => setState(
() => _simpleInputMode = !_simpleInputMode,
);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(
onDigitSelected: (index) => _onDigitSelected(
index,
autovalidate: true,
),
)
: RotaryDialInput(
animationDuration: _animationDuration,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Add new transition animation-related properties to the rotary dial input widget...
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.modeChangeController,
required this.passcodeAnimationInProgress,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final AnimationController modeChangeController;
final bool passcodeAnimationInProgress;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating => _dialController.isAnimating;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart().then((_) async => await widget.onValidatePasscode());
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
… and pass them from the passcode input view.
import 'package:flutter/material.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView>
with SingleTickerProviderStateMixin {
late final AnimationController _modeChangeController;
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating =>
_modeChangeController.isAnimating || _passcodeAnimationInProgress;
void initState() {
super.initState();
_modeChangeController = AnimationController(
duration: _animationDuration * 2,
vsync: this,
)..addListener(() => setState(() {}));
_resetDigits();
}
void dispose() {
_modeChangeController.dispose();
super.dispose();
}
void _onDigitSelected(int index, {bool autovalidate = false}) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
if (autovalidate) _validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() {
if (_modeChangeController.isCompleted) {
_changeInputMode();
Future.delayed(_animationDuration, () => _modeChangeController.reverse());
} else {
_modeChangeController.forward().then((_) => _changeInputMode());
}
}
void _changeInputMode() => setState(
() => _simpleInputMode = !_simpleInputMode,
);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment:
_simpleInputMode ? Alignment.center : Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(
onDigitSelected: (index) => _onDigitSelected(
index,
autovalidate: true,
),
)
: RotaryDialInput(
animationDuration: _animationDuration,
modeChangeController: _modeChangeController,
passcodeAnimationInProgress:
_passcodeAnimationInProgress,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
);
}
}
Create separate animations for the rotary dial foreground and background elements, and update the _isAnimating
getter. The rotary dial foreground animation runs during the first half of the transition and updates the sweep angle (basically, the arc’s length) from its maximum value to 0. The background animation runs during the second half of the transition and changes its value from 1 to 0.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.modeChangeController,
required this.passcodeAnimationInProgress,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final AnimationController modeChangeController;
final bool passcodeAnimationInProgress;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
late Animation<double> _rotaryDialForegroundAnimation;
late Animation<double> _rotaryDialBackgroundAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating =>
_dialController.isAnimating ||
widget.modeChangeController.isAnimating ||
widget.passcodeAnimationInProgress;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
_rotaryDialForegroundAnimation = Tween<double>(
begin: Constants.maxRotaryRingSweepAngle,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
),
);
_rotaryDialBackgroundAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart().then((_) async => await widget.onValidatePasscode());
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
),
),
),
],
);
},
);
}
}
Add the sweepAngle
property to the foreground painter and update the shouldRepaint
method.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
required this.startAngleOffset,
required this.sweepAngle,
});
final double numberRadiusFromCenter;
final double startAngleOffset;
final double sweepAngle;
void paint(Canvas canvas, Size size) {
const firstDialNumberPosition = Constants.firstDialNumberPosition;
const ringWidth = Constants.rotaryRingWidth;
final angleOffset = startAngleOffset * firstDialNumberPosition;
final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;
canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
angleOffset + firstDialNumberPosition,
Constants.maxRotaryRingSweepAngle,
false,
paint,
);
for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
angleOffset + math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);
canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
canvas.drawCircle(
size.centerOffset +
Offset.fromDirection(math.pi / 6, numberRadiusFromCenter),
ringWidth / 6,
Paint()..color = const Color.fromRGBO(255, 255, 255, 1.0),
);
canvas.restore();
}
bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter &&
oldDelegate.startAngleOffset != startAngleOffset &&
oldDelegate.sweepAngle != sweepAngle;
}
The compiler asks us to pass the sweepAngle
property. We do that and provide the current foreground animation value.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.modeChangeController,
required this.passcodeAnimationInProgress,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final AnimationController modeChangeController;
final bool passcodeAnimationInProgress;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
late Animation<double> _rotaryDialForegroundAnimation;
late Animation<double> _rotaryDialBackgroundAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating =>
_dialController.isAnimating ||
widget.modeChangeController.isAnimating ||
widget.passcodeAnimationInProgress;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
_rotaryDialForegroundAnimation = Tween<double>(
begin: Constants.maxRotaryRingSweepAngle,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
),
);
_rotaryDialBackgroundAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart().then((_) async => await widget.onValidatePasscode());
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: const RotaryDialBackgroundPainter(),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
sweepAngle: _rotaryDialForegroundAnimation.value,
),
),
),
],
);
},
);
}
}
Now, instead of using the constant sweep angle value, we use the provided property. Also, we use the property to calculate the opacity of a dial stop. Initially, the sweepAngle
and the maximum sweep angle are equal, thus the opacity is 1. During the transition animation, the sweepAngle
value will gradually get closer and close to 0, as well as the opacity of the dial's stop. Smart and simple, right?
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialForegroundPainter extends CustomPainter {
const RotaryDialForegroundPainter({
required this.numberRadiusFromCenter,
required this.startAngleOffset,
required this.sweepAngle,
});
final double numberRadiusFromCenter;
final double startAngleOffset;
final double sweepAngle;
void paint(Canvas canvas, Size size) {
const firstDialNumberPosition = Constants.firstDialNumberPosition;
const ringWidth = Constants.rotaryRingWidth;
final angleOffset = startAngleOffset * firstDialNumberPosition;
final paint = Paint()
..color = const Color.fromARGB(255, 255, 255, 255)
..strokeCap = StrokeCap.round
..strokeWidth = ringWidth - Constants.rotaryRingPadding * 2
..style = PaintingStyle.stroke;
canvas
..saveLayer(Rect.largest, paint)
..drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
angleOffset + firstDialNumberPosition,
sweepAngle,
false,
paint,
);
for (int i = 0; i < 10; i++) {
final offset = Offset.fromDirection(
angleOffset + math.pi * (-30 - i * 30) / 180,
numberRadiusFromCenter,
);
canvas.drawCircle(
size.centerOffset + offset,
Constants.dialNumberRadius,
Paint()..blendMode = BlendMode.clear,
);
}
canvas.drawCircle(
size.centerOffset +
Offset.fromDirection(math.pi / 6, numberRadiusFromCenter),
ringWidth / 6,
Paint()
..color = Color.fromRGBO(
255,
255,
255,
sweepAngle / Constants.maxRotaryRingSweepAngle,
),
);
canvas.restore();
}
bool shouldRepaint(RotaryDialForegroundPainter oldDelegate) =>
oldDelegate.numberRadiusFromCenter != numberRadiusFromCenter &&
oldDelegate.startAngleOffset != startAngleOffset &&
oldDelegate.sweepAngle != sweepAngle;
}
Now, the rotary dial foreground should gradually disappear during the transition. Let's take care of the dial's background transition next.
Hiding the rotary dial background
Add the opacity
property to the background painter and update the shouldRepaint
method.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialBackgroundPainter extends CustomPainter {
const RotaryDialBackgroundPainter({
required this.opacity,
});
final double opacity;
void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;
final paint = Paint()
..color = const Color.fromRGBO(0, 0, 0, 1.0)
..strokeWidth = ringWidth
..style = PaintingStyle.stroke;
canvas.drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
0,
math.pi * 2.0,
false,
paint,
);
}
bool shouldRepaint(RotaryDialBackgroundPainter oldDelegate) =>
oldDelegate.opacity != opacity;
}
The compiler cries in errors, so pass the background animation’s value as opacity for the background painter.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import '../../constants.dart';
import '../../utils.dart';
import '../dial_number.dart';
import 'rotary_dial_background_painter.dart';
import 'rotary_dial_foreground_painter.dart';
class RotaryDialInput extends StatefulWidget {
const RotaryDialInput({
required this.animationDuration,
required this.modeChangeController,
required this.passcodeAnimationInProgress,
required this.onDigitSelected,
required this.onValidatePasscode,
super.key,
});
final Duration animationDuration;
final AnimationController modeChangeController;
final bool passcodeAnimationInProgress;
final ValueSetter<int> onDigitSelected;
final AsyncCallback onValidatePasscode;
State<RotaryDialInput> createState() => _RotaryDialInputState();
}
class _RotaryDialInputState extends State<RotaryDialInput>
with SingleTickerProviderStateMixin {
late final AnimationController _dialController;
late Animation<double> _rotationAnimation;
late Animation<double> _rotaryDialForegroundAnimation;
late Animation<double> _rotaryDialBackgroundAnimation;
var _currentDragOffset = Offset.zero;
var _startAngleOffset = 0.0;
bool get _isAnimating =>
_dialController.isAnimating ||
widget.modeChangeController.isAnimating ||
widget.passcodeAnimationInProgress;
void initState() {
super.initState();
_dialController = AnimationController(
duration: widget.animationDuration,
vsync: this,
)..addListener(
() => setState(() => _startAngleOffset = _rotationAnimation.value),
);
_rotaryDialForegroundAnimation = Tween<double>(
begin: Constants.maxRotaryRingSweepAngle,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.0, 0.5, curve: Curves.easeInOut),
),
);
_rotaryDialBackgroundAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: widget.modeChangeController,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
}
void dispose() {
_dialController.dispose();
super.dispose();
}
TickerFuture _rotateDialToStart() {
_rotationAnimation = Tween<double>(
begin: _startAngleOffset,
end: 0.0,
).animate(
CurvedAnimation(parent: _dialController, curve: Curves.easeInOut),
);
_dialController.reset();
return _dialController.forward();
}
void _onPanStart(DragStartDetails details, Offset centerOffset) {
if (_isAnimating) return;
_currentDragOffset = details.localPosition - centerOffset;
}
void _onPanUpdate(DragUpdateDetails details, Offset centerOffset) {
if (_isAnimating) return;
final previousOffset = _currentDragOffset;
_currentDragOffset += details.delta;
final currentDirection = _currentDragOffset.direction;
final previousDirection = previousOffset.direction;
if (currentDirection * previousDirection < 0.0) return;
final angle = _startAngleOffset + currentDirection - previousDirection;
if (angle < 0.0 || angle >= Constants.maxRotaryRingAngle) return;
setState(() => _startAngleOffset = angle);
}
void _onPanEnd(DragEndDetails details) {
if (_isAnimating) return;
final offset = Constants.firstDialNumberPosition * (_startAngleOffset - 1);
if (offset < -math.pi / 12) {
_rotateDialToStart();
return;
}
final numberIndex = ((offset * 180 / math.pi).abs() / 30).round();
_addDigit(numberIndex);
}
void _addDigit(int index) {
widget.onDigitSelected(index);
_rotateDialToStart().then((_) async => await widget.onValidatePasscode());
}
Widget build(BuildContext context) {
const inputValues = Constants.inputValues;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final size = Size(width, width);
final centerOffset = size.centerOffset;
final dialNumberDistanceFromCenter = width / 2 -
16.0 - // page padding
Constants.rotaryRingPadding * 2 -
Constants.dialNumberPadding * 2;
return Stack(
alignment: Alignment.center,
children: [
CustomPaint(
size: size,
painter: RotaryDialBackgroundPainter(
opacity: _rotaryDialBackgroundAnimation.value,
),
),
for (var i = 0; i < inputValues.length; i++)
Transform.translate(
offset: Offset.fromDirection(
(i + 1) * -math.pi / 6,
dialNumberDistanceFromCenter,
),
child: DialNumber(inputValues[i]),
),
GestureDetector(
onPanStart: (details) => _onPanStart(details, centerOffset),
onPanUpdate: (details) => _onPanUpdate(details, centerOffset),
onPanEnd: _onPanEnd,
child: CustomPaint(
size: size,
painter: RotaryDialForegroundPainter(
numberRadiusFromCenter: dialNumberDistanceFromCenter,
startAngleOffset: _startAngleOffset,
sweepAngle: _rotaryDialForegroundAnimation.value,
),
),
),
],
);
},
);
}
}
Simply, use the opacity property for the painter’s colour. That’s it, really.
import 'dart:math' as math;
import 'package:flutter/rendering.dart';
import '../../constants.dart';
import '../../utils.dart';
class RotaryDialBackgroundPainter extends CustomPainter {
const RotaryDialBackgroundPainter({
required this.opacity,
});
final double opacity;
void paint(Canvas canvas, Size size) {
const ringWidth = Constants.rotaryRingWidth;
final paint = Paint()
..color = Color.fromRGBO(0, 0, 0, opacity)
..strokeWidth = ringWidth
..style = PaintingStyle.stroke;
canvas.drawArc(
Rect.fromCircle(
center: size.centerOffset,
radius: size.width / 2 - ringWidth / 2,
),
0,
math.pi * 2.0,
false,
paint,
);
}
bool shouldRepaint(RotaryDialBackgroundPainter oldDelegate) =>
oldDelegate.opacity != opacity;
}
The rotary dial background should gradually disappear now. Let’s move to the last part of the transition flow - hero animations.
Passcode digits component transition
From the hero animations documentation on the flutter.dev page:
The hero refers to the widget that flies between screens.
And that’s actually a problem. In our UI, we swap the UI elements but do not navigate between pages, thus the default hero animations do not really work for us. And boy, we are lazy. Happily, some smarter people implemented the local_hero package. It’s a package that allows running hero animations while staying in the same route. Simply, add the package to pubspec.yaml file.
name: flutter_rotary_passcode
description: A new Flutter project.
publish_to: "none"
version: 1.0.0+1
environment:
sdk: ">=3.0.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
google_fonts: ^6.1.0
local_hero: ^0.3.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
flutter:
uses-material-design: true
As per the documentation, define the LocalHeroScope
for your UI. We simply wrap the whole PasscodeInputView
content.
import 'package:flutter/material.dart';
import 'package:local_hero/local_hero.dart';
import 'constants.dart';
import 'widgets/input_mode_button.dart';
import 'widgets/passcode/passcode_digits.dart';
import 'widgets/passcode/passcode_input.dart';
import 'widgets/rotary_dial/rotary_dial_input.dart';
const _animationDuration = Duration(milliseconds: 500);
const _padding = 16.0;
class PasscodeInputView extends StatefulWidget {
const PasscodeInputView({
required this.expectedCode,
required this.onSuccess,
required this.onError,
super.key,
});
final String expectedCode;
final VoidCallback onSuccess;
final VoidCallback onError;
State<PasscodeInputView> createState() => _PasscodeInputViewState();
}
class _PasscodeInputViewState extends State<PasscodeInputView>
with SingleTickerProviderStateMixin {
late final AnimationController _modeChangeController;
late List<PasscodeDigit> _passcodeDigitValues;
var _currentInputIndex = 0;
var _simpleInputMode = false;
var _passcodeAnimationInProgress = false;
bool get _isAnimating =>
_modeChangeController.isAnimating || _passcodeAnimationInProgress;
void initState() {
super.initState();
_modeChangeController = AnimationController(
duration: _animationDuration * 2,
vsync: this,
)..addListener(() => setState(() {}));
_resetDigits();
}
void dispose() {
_modeChangeController.dispose();
super.dispose();
}
void _onDigitSelected(int index, {bool autovalidate = false}) {
if (_isAnimating) return;
final digitValue = _passcodeDigitValues[_currentInputIndex];
setState(() {
_passcodeDigitValues[_currentInputIndex++] = digitValue.copyWith(
value: Constants.inputValues[index],
);
});
if (autovalidate) _validatePasscode();
}
void _resetDigits() => setState(() {
_currentInputIndex = 0;
_passcodeDigitValues = List.generate(
widget.expectedCode.length,
(index) => const PasscodeDigit(
backgroundColor: Colors.white,
fontColor: Colors.white,
),
growable: false,
);
});
Future<void> _validatePasscode() async {
if (_isAnimating) return;
final expectedCode = widget.expectedCode;
if (_currentInputIndex != expectedCode.length) return;
final interval = _animationDuration.inMilliseconds ~/ expectedCode.length;
final codeInput = _passcodeDigitValues.fold<String>(
'',
(code, element) => code += element.value?.toString() ?? '',
);
_togglePasscodeAnimation();
if (codeInput == expectedCode) {
await _changePasscodeDigitColors(
backgroundColor: Colors.green,
fontColor: Colors.transparent,
interval: interval,
);
widget.onSuccess();
} else {
await _changePasscodeDigitColors(
backgroundColor: Colors.red,
fontColor: Colors.white,
interval: interval,
);
await Future.delayed(const Duration(seconds: 1));
await _changePasscodeDigitColors(
backgroundColor: Colors.white,
fontColor: Colors.white,
interval: interval,
);
widget.onError();
}
await Future.delayed(_animationDuration);
_resetDigits();
_togglePasscodeAnimation();
}
Future<void> _changePasscodeDigitColors({
Color? backgroundColor,
Color? fontColor,
int interval = 0,
}) async {
for (var i = 0; i < _passcodeDigitValues.length; i++) {
await Future.delayed(Duration(milliseconds: interval));
setState(() {
if (backgroundColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
backgroundColor: backgroundColor,
);
}
if (fontColor != null) {
_passcodeDigitValues[i] = _passcodeDigitValues[i].copyWith(
fontColor: fontColor,
);
}
});
}
}
void _togglePasscodeAnimation() => setState(
() => _passcodeAnimationInProgress = !_passcodeAnimationInProgress,
);
void _onModeChanged() {
if (_modeChangeController.isCompleted) {
_changeInputMode();
Future.delayed(_animationDuration, () => _modeChangeController.reverse());
} else {
_modeChangeController.forward().then((_) => _changeInputMode());
}
}
void _changeInputMode() => setState(
() => _simpleInputMode = !_simpleInputMode,
);
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
_padding,
_padding * 3,
_padding,
_padding * 2,
),
child: LocalHeroScope(
curve: Curves.easeInOut,
duration: _animationDuration,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Enter\npasscode'.toUpperCase(),
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: Colors.black,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32.0),
Align(
alignment: _simpleInputMode
? Alignment.center
: Alignment.centerRight,
child: PasscodeDigits(
animationDuration: _animationDuration,
passcodeDigitValues: _passcodeDigitValues,
simpleInputMode: _simpleInputMode,
),
),
const SizedBox(height: 16.0),
Expanded(
child: _simpleInputMode
? PasscodeInput(
onDigitSelected: (index) => _onDigitSelected(
index,
autovalidate: true,
),
)
: RotaryDialInput(
animationDuration: _animationDuration,
modeChangeController: _modeChangeController,
passcodeAnimationInProgress:
_passcodeAnimationInProgress,
onDigitSelected: _onDigitSelected,
onValidatePasscode: _validatePasscode,
),
),
Align(
alignment: Alignment.centerRight,
child: InputModeButton(
animationDuration: _animationDuration,
simpleInputMode: _simpleInputMode,
onModeChanged: _onModeChanged,
),
),
],
),
),
),
),
);
}
}
Let’s start with the PasscodeDigits
component and wrap each digit in a row with the LocalHero
widget.
import 'package:flutter/material.dart';
import 'package:local_hero/local_hero.dart';
import '../../utils.dart';
const _passcodeDigitPadding = 8.0;
const _passcodeDigitSizeBig = 36.0;
const _passcodeDigitSizeSmall = 24.0;
const _passcodeDigitGapBig = 16.0;
const _passcodeDigitGapSmall = 4.0;
class PasscodeDigit {
const PasscodeDigit({
required this.backgroundColor,
required this.fontColor,
this.value,
});
final Color backgroundColor;
final Color fontColor;
final int? value;
PasscodeDigit copyWith({
Color? backgroundColor,
Color? fontColor,
int? value,
}) =>
PasscodeDigit(
backgroundColor: backgroundColor ?? this.backgroundColor,
fontColor: fontColor ?? this.fontColor,
value: value ?? this.value,
);
}
class PasscodeDigits extends StatelessWidget {
const PasscodeDigits({
required this.animationDuration,
required this.passcodeDigitValues,
required this.simpleInputMode,
super.key,
});
final Duration animationDuration;
final List<PasscodeDigit> passcodeDigitValues;
final bool simpleInputMode;
Widget build(BuildContext context) {
return SizedBox(
height: _passcodeDigitSizeBig,
child: Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (var i = 0; i < passcodeDigitValues.length; i++)
LocalHero(
tag: 'passcode_digit_$i',
child: _PasscodeDigitContainer(
animationDuration: animationDuration,
backgroundColor: passcodeDigitValues[i].backgroundColor,
fontColor: passcodeDigitValues[i].fontColor,
digit: passcodeDigitValues[i].value,
size: simpleInputMode
? _passcodeDigitSizeBig
: _passcodeDigitSizeSmall,
),
),
].addBetween(
SizedBox(
width:
simpleInputMode ? _passcodeDigitGapBig : _passcodeDigitGapSmall,
),
),
),
);
}
}
class _PasscodeDigitContainer extends StatelessWidget {
const _PasscodeDigitContainer({
required this.animationDuration,
required this.backgroundColor,
required this.fontColor,
required this.digit,
required this.size,
});
final Duration animationDuration;
final Color backgroundColor;
final Color fontColor;
final int? digit;
final double size;
Widget build(BuildContext context) {
final digitContainerSize = size - _passcodeDigitPadding;
final containerSize = digit != null ? digitContainerSize : 0.0;
return Container(
height: size,
width: size,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: AnimatedContainer(
height: containerSize,
width: containerSize,
duration: animationDuration,
curve: Curves.easeInOut,
decoration: BoxDecoration(
color: backgroundColor,
shape: BoxShape.circle,
),
child: digit != null
? Center(
child: Text(
'$digit',
style: Theme.of(context).textTheme.bodyLarge!.copyWith(
color: fontColor,
fontWeight: FontWeight.bold,
height: 1.2,
),
),
)
: null,
),
);
}
}
And… well… that’s it. Passcode digits should gradually transition to the centre and change their size on mode change.
Dial numbers' transition
We apply the exact same logic to the DialNumber
widget by wrapping it with the LocaHero
widget.
import 'package:flutter/material.dart';
import 'package:local_hero/local_hero.dart';
import '../constants.dart';
class DialNumber extends StatelessWidget {
const DialNumber(
this.number, {
super.key,
});
final int number;
Widget build(BuildContext context) {
return LocalHero(
tag: 'digit_$number',
child: Container(
height: Constants.dialNumberRadius * 2,
width: Constants.dialNumberRadius * 2,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
child: Text(
'$number',
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
),
),
);
}
}
Since we use the same widget in both, rotary dial and simple passcode input views, no other changes are needed. Noice!
Summary
It was a great UI challenge that covered a lot of different components of Flutter needed to build custom UIs: custom painters, implicit, explicit, and staggered animations. If you want to dive deeper into the code, you can find the full source code on GitHub. In case of any questions or suggestions - feel free to reach out to me on X or any other social media channel.