From 748dbc235ce8c15b3bbf12acb17ee9ed633a12e2 Mon Sep 17 00:00:00 2001 From: futaba3 Date: Fri, 26 Jun 2026 15:32:41 +0900 Subject: [PATCH] feat: add showToolbarOnTap option for iOS single-tap context menu --- lib/src/pinput.dart | 16 +++++++ lib/src/pinput_state.dart | 16 ++++++- ...ut_selection_gesture_detector_builder.dart | 21 +++++++- test/pinput_test.dart | 48 +++++++++++++++++++ 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/lib/src/pinput.dart b/lib/src/pinput.dart index e0c8c08..fafd6d1 100644 --- a/lib/src/pinput.dart +++ b/lib/src/pinput.dart @@ -79,6 +79,7 @@ class Pinput extends StatefulWidget { this.readOnly = false, this.useNativeKeyboard = true, this.toolbarEnabled = true, + this.showToolbarOnTap = false, this.autofocus = false, this.obscureText = false, this.showCursor = true, @@ -145,6 +146,7 @@ class Pinput extends StatefulWidget { this.readOnly = false, this.useNativeKeyboard = true, this.toolbarEnabled = true, + this.showToolbarOnTap = false, this.autofocus = false, this.enableIMEPersonalizedLearning = false, this.enableInteractiveSelection = false, @@ -319,6 +321,13 @@ class Pinput extends StatefulWidget { /// If true, paste button will appear on longPress event final bool toolbarEnabled; + /// If true, a single tap on an already-focused Pinput will toggle the context + /// menu on iOS, matching the standard [TextField] behavior. + /// + /// Defaults to false to preserve the existing Pinput behavior. + /// Only affects iOS; other platforms are unaffected. + final bool showToolbarOnTap; + /// Whether show cursor or not /// Default cursor '|' or [cursor] final bool showCursor; @@ -695,6 +704,13 @@ class Pinput extends StatefulWidget { defaultValue: true, ), ); + properties.add( + DiagnosticsProperty( + 'showToolbarOnTap', + showToolbarOnTap, + defaultValue: false, + ), + ); properties.add( DiagnosticsProperty( 'showCursor', diff --git a/lib/src/pinput_state.dart b/lib/src/pinput_state.dart index 601f1d1..857220d 100644 --- a/lib/src/pinput_state.dart +++ b/lib/src/pinput_state.dart @@ -32,6 +32,7 @@ class _PinputState extends State RestorableTextEditingController? _controller; FocusNode? _focusNode; bool _isHovering = false; + bool _isToolbarVisible = false; String? _validatorErrorText; SmsRetriever? _smsRetriever; @@ -85,6 +86,7 @@ class _PinputState extends State } _effectiveFocusNode.canRequestFocus = isEnabled && widget.useNativeKeyboard; + _effectiveFocusNode.addListener(_handleFocusChange); _maybeInitSmartAuth(); _maybeCheckClipboard(); // https://github.com/Tkko/Flutter_Pinput/issues/89 @@ -191,6 +193,7 @@ class _PinputState extends State @override void dispose() { + _effectiveFocusNode.removeListener(_handleFocusChange); widget.controller?.removeListener(_handleTextEditingControllerChanges); _controller?.removeListener(_handleTextEditingControllerChanges); _controller?.dispose(); @@ -201,6 +204,12 @@ class _PinputState extends State super.dispose(); } + void _handleFocusChange() { + if (!_effectiveFocusNode.hasFocus) { + _isToolbarVisible = false; + } + } + void _requestKeyboard() { if (_effectiveFocusNode.canRequestFocus) { _editableText?.requestKeyboard(); @@ -445,7 +454,12 @@ class _PinputState extends State backgroundCursorColor: Colors.transparent, selectionHeightStyle: BoxHeightStyle.tight, enableSuggestions: widget.enableSuggestions, - contextMenuBuilder: widget.contextMenuBuilder, + contextMenuBuilder: widget.contextMenuBuilder == null + ? null + : (context, editableTextState) { + _isToolbarVisible = true; + return widget.contextMenuBuilder!(context, editableTextState); + }, obscuringCharacter: widget.obscuringCharacter, onAppPrivateCommand: widget.onAppPrivateCommand, onSelectionChanged: _handleSelectionChanged, diff --git a/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart b/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart index b6648f6..51bf52d 100644 --- a/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart +++ b/lib/src/widgets/_pinput_selection_gesture_detector_builder.dart @@ -18,8 +18,25 @@ class _PinputSelectionGestureDetectorBuilder @override void onSingleTapUp(details) { - super.onSingleTapUp(details); - editableText.hideToolbar(); + // pinput's _handleSelectionChanged forcibly rewrites the selection on every + // tap, so the framework's toggleToolbar logic (which compares previousSelection + // == currentSelection) always falls into hideToolbar instead of toggling. + // We bypass super and manually implement the iOS toggle behavior. + if (delegate.selectionEnabled && + _state.widget.showToolbarOnTap && + defaultTargetPlatform == TargetPlatform.iOS && + _state._effectiveFocusNode.hasFocus) { + if (_state._isToolbarVisible) { + _state._isToolbarVisible = false; + editableText.hideToolbar(false); + } else { + // toggleToolbar creates _selectionOverlay if null, which showToolbar() alone cannot. + editableText.toggleToolbar(false); + } + } else { + super.onSingleTapUp(details); + editableText.hideToolbar(); + } _state._requestKeyboard(); _state.widget.onTap?.call(); } diff --git a/test/pinput_test.dart b/test/pinput_test.dart index 2cd21da..7213c77 100644 --- a/test/pinput_test.dart +++ b/test/pinput_test.dart @@ -291,6 +291,54 @@ void main() { expect(tapCount, 3); }); + testWidgets( + 'onSingleTapUp shows context menu on second tap on iOS (already focused)', + (WidgetTester tester) async { + final focusNode = FocusNode(); + int tapCount = 0; + + await tester.pumpApp( + Pinput( + focusNode: focusNode, + showToolbarOnTap: true, + onTap: () => ++tapCount, + ), + ); + + // First tap: gains focus, no menu + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 1); + expect(focusNode.hasFocus, isTrue); + + // Second tap while focused: onTap is still called + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); + + testWidgets( + 'onSingleTapUp on non-iOS still calls onTap', + (WidgetTester tester) async { + int tapCount = 0; + + await tester.pumpApp( + Pinput( + onTap: () => ++tapCount, + ), + ); + + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byType(EditableText)); + await tester.pump(const Duration(milliseconds: 300)); + expect(tapCount, 2); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + testWidgets('onTap is not called, field is disabled', (WidgetTester tester) async { int tapCount = 0;