Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SuperEditor][Android][iOS] - Add tooling to more easily create keyboard toolbar experiences (Resolves #2209) #2216

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

299 changes: 254 additions & 45 deletions super_editor/example/lib/demos/mobile_chat/demo_mobile_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,87 +9,251 @@ class MobileChatDemo extends StatefulWidget {
}

class _MobileChatDemoState extends State<MobileChatDemo> {
final FocusNode _screenFocusNode = FocusNode();

final FocusNode _editorFocusNode = FocusNode();
late final Editor _editor;

late final KeyboardPanelController _keyboardPanelController;
final SoftwareKeyboardController _softwareKeyboardController = SoftwareKeyboardController();

final _imeConnectionNotifier = ValueNotifier<bool>(false);

_Panel? _visiblePanel;

@override
void initState() {
super.initState();

final document = MutableDocument.empty();
final composer = MutableDocumentComposer();
_editor = createDefaultDocumentEditor(document: document, composer: composer);

_keyboardPanelController = KeyboardPanelController(_softwareKeyboardController);

// Initially focus the overall screen so that the software keyboard isn't immediately
// visible.
_screenFocusNode.requestFocus();
}

@override
void dispose() {
_imeConnectionNotifier.dispose();
_keyboardPanelController.dispose();
_editorFocusNode.dispose();
_screenFocusNode.dispose();
super.dispose();
}

void _togglePanel(_Panel panel) {
setState(() {
if (_visiblePanel == panel) {
_visiblePanel = null;
_keyboardPanelController.showSoftwareKeyboard();
} else {
_visiblePanel = panel;
_keyboardPanelController.showKeyboardPanel();
}
});
}

@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: ColoredBox(color: Colors.white),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildCommentEditor(),
),
],
return KeyboardScaffoldSafeArea(
child: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () {
_screenFocusNode.requestFocus();
},
child: Focus(
focusNode: _screenFocusNode,
child: ColoredBox(color: Colors.white),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildCommentEditor(),
),
],
),
);
}

Widget _buildCommentEditor() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
return Opacity(
opacity: 0.5,
child: KeyboardPanelScaffold(
controller: _keyboardPanelController,
isImeConnected: _imeConnectionNotifier,
toolbarBuilder: _buildKeyboardToolbar,
fallbackPanelHeight: MediaQuery.sizeOf(context).height / 3,
keyboardPanelBuilder: (context) {
switch (_visiblePanel) {
case _Panel.panel1:
return Container(
color: Colors.blue,
height: double.infinity,
);
case _Panel.panel2:
return Container(
color: Colors.red,
height: double.infinity,
);
default:
return const SizedBox();
}
},
contentBuilder: (context, isKeyboardVisible) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(24),
topRight: Radius.circular(24),
),
border: Border(
top: BorderSide(width: 1, color: Colors.grey),
left: BorderSide(width: 1, color: Colors.grey),
right: BorderSide(width: 1, color: Colors.grey),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.075),
blurRadius: 8,
spreadRadius: 4,
),
],
),
border: Border(
top: BorderSide(width: 1, color: Colors.grey),
left: BorderSide(width: 1, color: Colors.grey),
right: BorderSide(width: 1, color: Colors.grey),
padding: const EdgeInsets.only(top: 16),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SliverPadding(
padding: const EdgeInsets.only(bottom: 24),
sliver: SuperEditor(
editor: _editor,
focusNode: _editorFocusNode,
softwareKeyboardController: _softwareKeyboardController,
shrinkWrap: true,
stylesheet: _chatStylesheet,
selectionPolicies: const SuperEditorSelectionPolicies(
clearSelectionWhenEditorLosesFocus: true,
clearSelectionWhenImeConnectionCloses: false,
),
isImeConnected: _imeConnectionNotifier,
),
),
],
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.075),
blurRadius: 8,
spreadRadius: 4,
),
],
),
padding: const EdgeInsets.only(top: 16, bottom: 24),
child: CustomScrollView(
shrinkWrap: true,
slivers: [
SuperEditor(
editor: _editor,
shrinkWrap: true,
stylesheet: _chatStylesheet,
);
},
),
);
}

Widget _buildKeyboardToolbar(BuildContext context, bool isKeyboardPanelVisible) {
if (!isKeyboardPanelVisible) {
_visiblePanel = null;
}

return Row(
children: [
Expanded(
child: Opacity(
opacity: 0.5,
child: Container(
width: double.infinity,
height: 54,
color: Colors.grey.shade100,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const SizedBox(width: 24),
const Spacer(),
_PanelButton(
icon: Icons.text_fields,
isActive: _visiblePanel == _Panel.panel1,
onPressed: () => _togglePanel(_Panel.panel1),
),
const SizedBox(width: 16),
_PanelButton(
icon: Icons.align_horizontal_left,
isActive: _visiblePanel == _Panel.panel2,
onPressed: () => _togglePanel(_Panel.panel2),
),
const SizedBox(width: 16),
_PanelButton(
icon: Icons.account_circle,
onPressed: () => _showBottomSheetWithOptions(context),
),
const Spacer(),
GestureDetector(
onTap: _keyboardPanelController.closeKeyboardAndPanel,
child: Icon(Icons.keyboard_hide),
),
const SizedBox(width: 24),
],
),
],
),
),
),
],
);
}
}

enum _Panel {
panel1,
panel2;
}

class _PanelButton extends StatelessWidget {
const _PanelButton({
required this.icon,
this.isActive = false,
required this.onPressed,
});

final IconData icon;
final bool isActive;
final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onPressed,
child: AspectRatio(
aspectRatio: 1.0,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isActive ? Colors.grey : Colors.transparent,
),
child: Icon(icon),
),
),
);
}
}

final _chatStylesheet = defaultStylesheet.copyWith(
addRulesAfter: [
addRulesBefore: [
StyleRule(
BlockSelector.all,
(doc, docNode) {
return {
Styles.maxWidth: null,
Styles.maxWidth: double.infinity,
Styles.padding: const CascadingPadding.symmetric(horizontal: 24),
};
},
),
],
addRulesAfter: [
StyleRule(
BlockSelector.all.first(),
(doc, docNode) {
Expand All @@ -108,3 +272,48 @@ final _chatStylesheet = defaultStylesheet.copyWith(
),
],
);

Future<void> _showBottomSheetWithOptions(BuildContext context) async {
return showModalBottomSheet(
context: context,
builder: (sheetContext) {
return _BottomSheetWithoutButtonOptions();
},
);
}

class _BottomSheetWithoutButtonOptions extends StatefulWidget {
const _BottomSheetWithoutButtonOptions();

@override
State<_BottomSheetWithoutButtonOptions> createState() => _BottomSheetWithoutButtonOptionsState();
}

class _BottomSheetWithoutButtonOptionsState extends State<_BottomSheetWithoutButtonOptions> {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
"This bottom sheet represents a feature in which the user wants to temporarily leave the editor, and the toolbar, to review or select an option. We expect the keyboard or panel to close when this opens, and to re-open when this closes.",
textAlign: TextAlign.left,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text("Some Options"),
),
],
),
);
}
}
1 change: 1 addition & 0 deletions super_editor/example/lib/main_super_editor_chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ void main() {
runApp(
MaterialApp(
home: Scaffold(
resizeToAvoidBottomInset: false,
body: MobileChatDemo(),
),
debugShowCheckedModeBanner: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,7 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
..hideToolbar()
..doNotBlinkCaret();
} else if (!widget.selection.value!.isCollapsed) {
// The selection is expanded.
_controlsController!
..hideCollapsedHandle()
..showExpandedHandles()
Expand All @@ -1002,9 +1003,13 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
..hideMagnifier()
..blinkCaret();

if (didTapOnExistingSelection) {
if (didTapOnExistingSelection && _isKeyboardOpen) {
// Toggle the toolbar display when the user taps on the collapsed caret,
// or on top of an existing selection.
//
// But we only do this when the keyboard is already open. This is because
// we don't want to show the toolbar when the user taps simply to open
// the keyboard. That would feel unintentional, like a bug.
_controlsController!.toggleToolbar();
} else {
// The user tapped somewhere else in the document. Hide the toolbar.
Expand All @@ -1013,6 +1018,16 @@ class _AndroidDocumentTouchInteractorState extends State<AndroidDocumentTouchInt
}
}

/// Returns `true` if we *think* the software keyboard is currently open, or
/// `false` otherwise.
///
/// We say "think" because Flutter doesn't report this info to us. Instead, we
/// inspect the bottom insets on the window, and we assume any insets greater than
/// zero means a keyboard is visible.
bool get _isKeyboardOpen {
return MediaQuery.viewInsetsOf(context).bottom > 0;
}

void _onPanStart(DragStartDetails details) {
// Stop waiting for a long-press to start, if a long press isn't already in-progress.
_tapDownLongPressTimer?.cancel();
Expand Down
Loading
Loading