Skip to main content

Flutter UI challenge: rotary passcode (animations)

· 82 min read

Retro rotary passcode input UI challenge made with Flutter. The main focus of this part is motion design - animations.

Header image - Rotary passcode Flutter UI challenge (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.

tip

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.

Default Flutter animated widgets

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).

tip

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.

Input mode button cross-fade animation

info

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.

input_mode_button.dart
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.

passcode_input_view.dart
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.

input_mode_button.dart
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.

input_mode_button.dart
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:

  1. Scale animation on digit input;
  2. Success animation on successful passcode input;
  3. Error animation sequence when the passcode is incorrect;
  4. Digits indicator size transition when switching input modes.

Digits indicator animation

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.

passcode_input.dart
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),
],
);
}
}
note

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.

passcode_digits.dart
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.

passcode_input_view.dart
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.

Exception thrown on passcode input validation

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...

passcode_input_view.dart
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.

main.dart
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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

passcode_digits.dart
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.

passcode_digits.dart
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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

Staggered animation scheme

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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

passcode_input_view.dart
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.

Rotary dial animation

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.

rotary_dial_input.dart
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.

passcode_input_view.dart
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) =