Last active
June 20, 2025 10:02
-
-
Save PlugFox/305cb44f75e8fa5e5f06424288d373e4 to your computer and use it in GitHub Desktop.
Pin code widget
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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