Skip to content

Instantly share code, notes, and snippets.

@PlugFox
Last active June 20, 2025 10:02
Show Gist options
  • Save PlugFox/305cb44f75e8fa5e5f06424288d373e4 to your computer and use it in GitHub Desktop.
Save PlugFox/305cb44f75e8fa5e5f06424288d373e4 to your computer and use it in GitHub Desktop.
Pin code widget
import 'dart:math' as math;
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// {@template pin_code}
/// PinCode widget.
/// {@endtemplate}
class PinCode extends StatefulWidget {
/// {@macro pin_code}
const PinCode({
required this.controller,
this.enabled = true,
this.textStyle,
this.onChanged,
this.onEditingComplete,
this.onSubmitted,
super.key,
});
/// Length of the pin code.
static const int length = 6;
final bool enabled;
final TextEditingController controller;
final TextStyle? textStyle;
final void Function(String value)? onChanged;
final void Function()? onEditingComplete;
final void Function(String value)? onSubmitted;
@override
State<PinCode> createState() => _PinCodeState();
}
/// State for widget PinCode.
class _PinCodeState extends State<PinCode> {
final FocusNode _internalFocus = FocusNode();
late TextEditingController _controller;
late _PinPainter _painter;
static Widget? _doNotBuildCounter(
BuildContext context, {
required int currentLength,
required int? maxLength,
required bool isFocused,
}) => null;
/* #region Lifecycle */
@override
void initState() {
super.initState();
_controller = widget.controller..addListener(_onValueChanged);
_painter = _PinPainter(
controller: _controller,
focusNode: _internalFocus,
textPainter: TextPainter(textDirection: TextDirection.ltr, textScaler: TextScaler.noScaling, maxLines: 1),
textStyle:
widget.textStyle?.copyWith(height: 1) ??
const TextStyle(
height: 1.0,
fontSize: 24.0,
color: Colors.black,
fontWeight: FontWeight.w600,
fontFamily: 'OpenSans',
package: 'ui',
),
);
}
@override
void didUpdateWidget(covariant PinCode oldWidget) {
super.didUpdateWidget(oldWidget);
_controller = widget.controller;
if (!identical(widget.controller, _controller)) {
_controller.removeListener(_onValueChanged);
_controller = widget.controller;
_painter = _painter.copyWith(controller: _controller);
}
if (!identical(widget.textStyle, oldWidget.textStyle)) {
_painter = _painter.copyWith(
textStyle:
widget.textStyle?.copyWith(height: 1) ??
const TextStyle(
height: 1.0,
fontSize: 24.0,
color: Colors.black,
fontWeight: FontWeight.w600,
fontFamily: 'OpenSans',
package: 'ui',
),
);
}
}
@override
void dispose() {
super.dispose();
_internalFocus.dispose();
_controller.removeListener(_onValueChanged);
}
/* #endregion */
void _onValueChanged() {
if (_controller.text.length > PinCode.length) {
_controller
..text = _controller.text.substring(0, PinCode.length)
..selection = TextSelection.fromPosition(TextPosition(offset: _controller.text.length));
}
}
@override
Widget build(BuildContext context) => Center(
child: SizedBox(
height: 48.0,
width: 338.0,
child: Builder(
builder:
(context) => Stack(
alignment: Alignment.center,
fit: StackFit.expand,
children: <Widget>[
Positioned.fill(
child: AbsorbPointer(
absorbing: true, // it prevents on tap on the text field
child: TextField(
enabled: widget.enabled,
canRequestFocus: true,
focusNode: _internalFocus,
controller: widget.controller,
cursorColor: Colors.transparent,
style: const TextStyle(fontSize: 10.0, height: 1.0, color: Colors.transparent),
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
enableInteractiveSelection: false,
showCursor: false,
cursorWidth: 1.0,
minLines: 1,
maxLines: 1,
autocorrect: false,
selectionControls: EmptyTextSelectionControls(),
selectionHeightStyle: BoxHeightStyle.tight,
selectionWidthStyle: BoxWidthStyle.tight,
textInputAction: TextInputAction.done,
maxLength: PinCode.length,
maxLengthEnforcement: MaxLengthEnforcement.enforced,
textDirection: TextDirection.ltr,
onChanged: widget.onChanged,
onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted,
decoration: const InputDecoration(
constraints: BoxConstraints(maxWidth: 338.0, maxHeight: 48.0),
filled: false,
fillColor: Colors.transparent,
maintainHintHeight: false,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
disabledBorder: InputBorder.none,
),
buildCounter: _doNotBuildCounter,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.singleLineFormatter,
LengthLimitingTextInputFormatter(PinCode.length),
FilteringTextInputFormatter.digitsOnly,
],
),
),
),
Positioned.fill(
child: GestureDetector(
onTapDown: (details) {
// Try to focus specific box when tapped
if (context.findRenderObject() case RenderBox box) {
final constraints = box.constraints;
final boxWidth = constraints.maxWidth / PinCode.length;
// Calculate the index of the box that was tapped
final index = (details.localPosition.dx / boxWidth).floor();
// Set the cursor position to the tapped box index
_controller.selection = TextSelection.fromPosition(
/* TextPosition(offset: index.clamp(0, _controller.text.length - 1)), */
TextPosition(
offset: index.clamp(0, math.max(0, math.min(PinCode.length - 1, _controller.text.length))),
),
);
}
// Request focus when the user taps on the pin code area
_internalFocus.requestFocus();
},
child: CustomPaint(isComplex: false, willChange: false, painter: _painter),
),
),
],
),
),
),
);
}
class _PinPainter extends CustomPainter {
_PinPainter({required this.controller, required this.focusNode, required this.textPainter, required this.textStyle})
: super(repaint: controller);
final TextEditingController controller;
final FocusNode focusNode;
final TextPainter textPainter;
final TextStyle textStyle;
final Paint _unfocusedBoxPaint =
Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 1.0
..isAntiAlias = true
..blendMode = BlendMode.src;
final Paint _focusedBoxPaint =
Paint()
..color = const Color.fromRGBO(35, 153, 101, 1.0)
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..isAntiAlias = true
..blendMode = BlendMode.src;
final Paint _cursorPaint =
Paint()
..color = Colors.blue
..strokeWidth = 2.0
..isAntiAlias = true
..blendMode = BlendMode.srcOver;
_PinPainter copyWith({
TextEditingController? controller,
FocusNode? focusNode,
TextPainter? textPainter,
TextStyle? textStyle,
}) => _PinPainter(
controller: controller ?? this.controller,
focusNode: focusNode ?? this.focusNode,
textPainter: textPainter ?? this.textPainter,
textStyle: textStyle ?? this.textStyle,
);
@override
void paint(Canvas canvas, Size size) {
final textLength = controller.text.length;
final hasFocus = focusNode.hasFocus;
const numBoxPadding = 8.0;
final numBoxSize = Size(size.width / PinCode.length - numBoxPadding * 2, size.height);
canvas.drawColor(Colors.white, BlendMode.src);
for (var i = 0; i < PinCode.length; i++) {
final x = (size.width / PinCode.length) * i;
final hasChar = i < textLength;
final focused = hasFocus && i == controller.selection.end;
final rect = Offset(x + numBoxPadding, 0) & numBoxSize;
canvas.drawRRect(
hasChar || focused
? RRect.fromRectAndRadius(rect, const Radius.circular(8.0))
: RRect.fromRectAndRadius(rect.deflate(4.0), const Radius.circular(8.0)),
hasFocus ? _focusedBoxPaint : _unfocusedBoxPaint,
);
// Draw the cursor line if the field has focus and the index is at the end of the text
if (focused && !hasChar) {
final centerX = x + (size.width / PinCode.length) / 2;
canvas.drawLine(Offset(centerX, 8), Offset(centerX, size.height - 8), _cursorPaint);
}
if (!hasChar) continue; // If there is no character at this index, skip drawing
// Draw the character in the box
textPainter
..text = TextSpan(
text: controller.text[i],
style:
focused
? textStyle.copyWith(color: textStyle.color?.withValues(alpha: .45) ?? Colors.black45)
: textStyle,
)
..layout(maxWidth: size.width / PinCode.length)
..paint(
canvas,
Offset(x + (size.width / PinCode.length - textPainter.width) / 2, (size.height - textPainter.height) / 2),
);
}
}
@override
bool shouldRepaint(_PinPainter oldDelegate) =>
!identical(oldDelegate.controller, controller) ||
!identical(oldDelegate.focusNode, focusNode) ||
!identical(oldDelegate.textPainter, textPainter) ||
!identical(oldDelegate.textStyle, textStyle);
@override
bool shouldRebuildSemantics(_PinPainter oldDelegate) => false;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment