I’m participating in nuggets Creators Camp # 4, click here to learn more and learn together!

The functional requirements

Recently, a problem of Flutter development was encountered in requirement development, in order to optimize user input experience. Product students hope to support the magnifying glass function in the input box when moving the cursor. It was thought to be a small requirement, as iOS and Android are known to come with this functionality on native systems. This was not the case when the development was implemented. Flutter did not seem to support its original function.

Demand research

To check whether the input box magnifying glass is officially supported, gogithubAfter searching the issue on the project, I found that this issue had been mentioned in 18 years, but the official has not supported the implementation.Since there is no official support, adhering to the idea of wheels I use to continue throughgithubSearch for developer customizations that implement this feature.

Searching Magnifier turns up an article that is an implementation of a magnifying glass, but it is not an implementation of a Magnifier on an input box. It only enlarges where the gesture touches the screen.

Since you can’t find a full implementation of the input box magnifier function, you have to implement the function yourself. According toMagnifierTo implement the magnifying glass function for the input box.

Need to implement

By using TextField, you can see that the TextToolBar function bar appears when the cursor is double-clicked or long-pressed. As the cursor moves, the editing bar above moves with the cursor. This discovery applies to the magnifying glass function: follow the cursor and zoom in to achieve the desired effect.

The source code interpretation

Before implementing the function, you need to read the TextField source code to understand how the edit bar above the cursor is implemented and can follow the cursor.

PS: Extended_TEXt_field is used for source code parsing due to the use of rich text input and display in the project.

Find ExtendedEditableText ExtendedTextField input box component source code in the view can see CompositedTransformTarget and _toolbarLayerLink build method. And these two are already the key information to realize the magnifying glass function.

About the use of CompositedTransformTarget can search on the Internet a lot, is used to bind the two View. There are other CompositedTransformFollower CompositedTransformTarget. Simple understanding is CompositedTransformFollower is binding, CompositedTransformTarget is bound, the former with the latter. The _toolbarLayerLink is the binding medium that follows the cursor action bar.

Return CompositedTransformTarget (link: _toolbarLayerLink, / / operation tool child: Semantics (... Child: _Editable(key: _editableKey, startHandleLayerLink: _startHandleLayerLink, // Left cursor position endHandleLayerLink: TextSpan: _buildTextSpan(context), value: _value, cursorColor: _cursorColor,......) ,),);Copy the code

Through the source query find _toolbarLayerLink ExtendedTextSelectionOverlay another user.

Void createSelectionOverlay({// Create action bar ExtendedRenderEditable? renderObject, bool showHandles = true, }) { _selectionOverlay = ExtendedTextSelectionOverlay( clipboardStatus: _clipboardStatus, context: context, value: _value, debugRequiredFor: widget, toolbarLayerLink: _toolbarLayerLink, startHandleLayerLink: _startHandleLayerLink, endHandleLayerLink: _endHandleLayerLink, renderObject: renderObject ?? renderEditable, selectionControls: widget.selectionControls, ..... ) ; .Copy the code

Can be found through the source query CompositedTransformFollower components use, can see selectionControls through code! .buildToolbar is the implementation of the edit bar.

return Directionality(
  textDirection: Directionality.of(this.context),
  child: FadeTransition(
    opacity: _toolbarOpacity,
    child: CompositedTransformFollower( // Trace component for action bar
      link: toolbarLayerLink,
      showWhenUnlinked: false,
      offset: -editingRegion.topLeft,
      child: Builder(
        builder: (BuildContext context) {
          returnselectionControls! .buildToolbar( context, editingRegion, renderObject.preferredLineHeight, midpoint, endpoints, selectionDelegate! , clipboardStatus! , renderObject.lastSecondaryTapDownPosition, ); },),),),);Copy the code

Then go back to find out how selectionControls are implemented. TextSelectionControls are created by default in the build method of _ExtendedTextFieldState. Due to android and iOS exist differences, therefore cupertinoTextSelectionControls and materialTextSelectionControls two selectionControls.

switch (theme.platform) { case TargetPlatform.iOS: final CupertinoThemeData cupertinoTheme = CupertinoTheme.of(context); forcePressEnabled = true; textSelectionControls ?? = cupertinoTextSelectionControls; . break; . case TargetPlatform.android: case TargetPlatform.fuchsia: forcePressEnabled = false; textSelectionControls ?? = materialTextSelectionControls; . break; . }Copy the code

Here just to see MaterialTextSelectionControls source implementation. In _TextSelectionControlsToolbar layout implementation. _TextSelectionHandlePainter is to draw the cursor style method.

 @override
  Widget build(BuildContext context) {
      // The left and right cursor positions
    final TextSelectionPoint startTextSelectionPoint = widget.endpoints[0];
    // Check if there are two cursors
    final TextSelectionPoint endTextSelectionPoint = widget.endpoints.length > 1
      ? widget.endpoints[1]
      : widget.endpoints[0];
    final Offset anchorAbove = Offset(
      widget.globalEditableRegion.left + widget.selectionMidpoint.dx,
      widget.globalEditableRegion.top + startTextSelectionPoint.point.dy - widget.textLineHeight - _kToolbarContentDistance,
    );
    finalOffset anchorBelow = Offset( widget.globalEditableRegion.left + widget.selectionMidpoint.dx, widget.globalEditableRegion.top + endTextSelectionPoint.point.dy + _kToolbarContentDistanceBelow, ); .return TextSelectionToolbar(
      anchorAbove: anchorAbove, // Left cursor
      anchorBelow: anchorBelow,// Right cursor
      children: itemDatas.asMap().entries.map((MapEntry<int, _TextSelectionToolbarItemData> entry) {
        return TextSelectionToolbarTextButton(
          padding: TextSelectionToolbarTextButton.getPadding(entry.key, itemDatas.length),
          onPressed: entry.value.onPressed,
          child: Text(entry.value.label), 
        );
      }).toList(), // Button function for each edit operation); }}/// Android select style draw (default is dot with an arrow)
class _TextSelectionHandlePainter extends CustomPainter {
  _TextSelectionHandlePainter({ required this.color });

  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    finalPaint paint = Paint().. color = color;final double radius = size.width/2.0;
    final Rect circle = Rect.fromCircle(center: Offset(radius, radius), radius: radius);
    final Rect point = Rect.fromLTWH(0.0.0.0, radius, radius);
    finalPath path = Path().. addOval(circle).. addRect(point); canvas.drawPath(path, paint); }@override
  bool shouldRepaint(_TextSelectionHandlePainter oldPainter) {
    return color != oldPainter.color;
  }
}
Copy the code

Function and name

After understanding source function can copy MaterialTextSelectionControls implementation to complete the function of magnifying glass. Is also inherited TextSelectionControls, realize MaterialMagnifierControls function.

The main modification points are the implementation of _MagnifierControlsToolbar and the MaterialMagnifier function

MagnifierControlsToolbar

The build method returns widget.endpoints cursor location information to calculate the offset. Finally, the two cursor information is referenced to the MaterialMagnifier component.

const double _kHandleSize = 22.0;

const double _kToolbarContentDistanceBelow = _kHandleSize - 2.0;
const double _kToolbarContentDistance = 8.0;

class MaterialMagnifierControls extends TextSelectionControls {

  @override
  Size getHandleSize(double textLineHeight) =>
      const Size(_kHandleSize, _kHandleSize);

  @override
  Widget buildToolbar(
    BuildContext context,
    Rect globalEditableRegion,
    double textLineHeight,
    Offset selectionMidpoint,
    List<TextSelectionPoint> endpoints,
    TextSelectionDelegate delegate,
    ClipboardStatusNotifier clipboardStatus,
    Offset? lastSecondaryTapDownPosition,
  ) {
    return _MagnifierControlsToolbar(
      globalEditableRegion: globalEditableRegion,
      textLineHeight: textLineHeight,
      selectionMidpoint: selectionMidpoint,
      endpoints: endpoints,
      delegate: delegate,
      clipboardStatus: clipboardStatus,
    );
  }

  @override
  Widget buildHandle(
      BuildContext context, TextSelectionHandleType type, double textHeight,
      [VoidCallback? onTap, double? startGlyphHeight, double? endGlyphHeight]) {
    return const SizedBox();
  }


  @override
  Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight,
      [double? startGlyphHeight, double? endGlyphHeight]) {
    switch (type) {
      case TextSelectionHandleType.left:
        return const Offset(_kHandleSize, 0);
      case TextSelectionHandleType.right:
        return Offset.zero;
      default:
        return const Offset(_kHandleSize / 2.4 -); }}}class _MagnifierControlsToolbar extends StatefulWidget {
  const _MagnifierControlsToolbar({
    Key? key,
    required this.clipboardStatus,
    required this.delegate,
    required this.endpoints,
    required this.globalEditableRegion,
    required this.selectionMidpoint,
    required this.textLineHeight,
  }) : super(key: key);

  final ClipboardStatusNotifier clipboardStatus;
  final TextSelectionDelegate delegate;
  final List<TextSelectionPoint> endpoints;
  final Rect globalEditableRegion;
  final Offset selectionMidpoint;
  final double textLineHeight;

  @override
  _MagnifierControlsToolbarState createState() =>
      _MagnifierControlsToolbarState();
}

class _MagnifierControlsToolbarState extends State<_MagnifierControlsToolbar>
    with TickerProviderStateMixin {

  Offset offset1 = Offset.zero;
  Offset offset2 = Offset.zero;
  void _onChangedClipboardStatus() {
    setState(() {
    });
  }

  @override
  void initState() {
    super.initState();
    widget.clipboardStatus.addListener(_onChangedClipboardStatus);
    widget.clipboardStatus.update();
  }

  @override
  void didUpdateWidget(_MagnifierControlsToolbar oldWidget) {
    super.didUpdateWidget(oldWidget);
    if(widget.clipboardStatus ! = oldWidget.clipboardStatus) { widget.clipboardStatus.addListener(_onChangedClipboardStatus); oldWidget.clipboardStatus.removeListener(_onChangedClipboardStatus); } widget.clipboardStatus.update(); }@override
  void dispose() {
    super.dispose();
    if (!widget.clipboardStatus.disposed) {
      widget.clipboardStatus.removeListener(_onChangedClipboardStatus);
    }
  }

  @override
  Widget build(BuildContext context) {
    TextSelectionPoint point = widget.endpoints[0];
    if(widget.endpoints.length > 1) {if(offset1 ! = widget.endpoints[0].point){
        point =  widget.endpoints[0];
        offset1 = point.point;
      }
      if(offset2 ! = widget.endpoints[1].point){
        point =  widget.endpoints[1]; offset2 = point.point; }}final TextSelectionPoint startTextSelectionPoint = point;

    final Offset anchorAbove = Offset(
      widget.globalEditableRegion.left + startTextSelectionPoint.point.dx,
      widget.globalEditableRegion.top +
          startTextSelectionPoint.point.dy -
          widget.textLineHeight -
          _kToolbarContentDistance,
    );
    final Offset anchorBelow = Offset(
      widget.globalEditableRegion.left + startTextSelectionPoint.point.dx,
      widget.globalEditableRegion.top +
          startTextSelectionPoint.point.dy +
          _kToolbarContentDistanceBelow,
    );

    returnMaterialMagnifier( anchorAbove: anchorAbove, anchorBelow: anchorBelow, textLineHeight: widget.textLineHeight, ); }}final TextSelectionControls materialMagnifierControls =
    MaterialMagnifierControls();
Copy the code

MaterialMagnifier

The MaterialMagnifier is an implementation of the reference Widget Magnifier. Some android layout parameters are introduced here. IOS layout parameters are also customized. You can refer to the official source code of Flutter to customize the iOS layout.

Magnifying glass implementation method is mainly BackdropFilter and ImageFilter to achieve, according to Matrix4 do scale and translate operation to complete the amplification function.

const double _kToolbarScreenPadding = 8.0;
const double _kToolbarHeight = 44.0;

class MaterialMagnifier extends StatelessWidget {

  const MaterialMagnifier({
    Key? key,
    required this.anchorAbove,
    required this.anchorBelow,
    required this.textLineHeight,
    this.size = const Size(90.50),
    this.scale = 1.7,}) :super(key: key);

  final Offset anchorAbove;
  final Offset anchorBelow;

  final Size size;
  final double scale;
  final double textLineHeight;

  @override
  Widget build(BuildContext context) {
    final double paddingAbove =
        MediaQuery.of(context).padding.top + _kToolbarScreenPadding;
    final double availableHeight = anchorAbove.dy - paddingAbove;
    final bool fitsAbove = _kToolbarHeight <= availableHeight;
    final Offset localAdjustment = Offset(_kToolbarScreenPadding, paddingAbove);
    finalMatrix4 updatedMatrix = Matrix4.identity() .. scale(1.1.1.1)
      ..translate(0.0.50.0);
    Matrix4 _matrix = updatedMatrix;
    return Container(
      child: Padding(
        padding: EdgeInsets.fromLTRB(
          _kToolbarScreenPadding,
          paddingAbove,
          _kToolbarScreenPadding,
          _kToolbarScreenPadding,
        ),
        child: Stack(
          children: <Widget>[
            CustomSingleChildLayout(
              delegate: TextSelectionToolbarLayoutDelegate(
                anchorAbove: anchorAbove - localAdjustment,
                anchorBelow: anchorBelow - localAdjustment,
                fitsAbove: fitsAbove,
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: BackdropFilter(
                  filter: ImageFilter.matrix(_matrix.storage),
                  child: CustomPaint(
                    painter: const MagnifierPainter(color: Color(0xFFdfdfdf)), size: size, ), ), ), ), ], ), ), ); }}Copy the code

Interactive optimization

In addition to realizing the magnifying glass function, it is also necessary to control the display. Because the magnifying glass is displayed in the dragging state and the operation bar function is hidden, it is necessary to monitor the gesture state information.

Gesture is listening in _TextSelectionHandleOverlayState, need to monitor onPanStart, onPanUpdate, onPanEnd, onPanCancel these status.

state action
onPanStart Hide the operation bar and display the magnifying glass
onPanUpdate Display the magnifying glass and obtain the offset information
onPanEnd Display the operation bar and hide the magnifying glass
onPanCancel Display the operation bar and hide the magnifying glass
finalWidget child = GestureDetector( behavior: HitTestBehavior.translucent, dragStartBehavior: widget.dragStartBehavior, onPanStart: _handleDragStart, onPanUpdate: _handleDragUpdate, onPanEnd: _handleDragEnd, onPanCancel: _handleDragCancel, onTap: _handleTap, child: Padding( padding: EdgeInsets.only( left: padding.left, top: padding.top, right: padding.right, bottom: padding.bottom, ), child: widget.selectionControls! .buildHandle( context, type, widget.renderObject.preferredLineHeight, () {}, ), ), );Copy the code

Show the magnifying glass and hide the action when you begin to expand the gesture. The _builderMagnifier is nested in the OverlayEntry component and inserted on the Overlay in exactly the same way as the action bar.

void _handleDragStart(DragStartDetails details) {
  finalSize handleSize = widget.selectionControls! .getHandleSize( widget.renderObject.preferredLineHeight, ); _dragPosition = details.globalPosition + Offset(0.0, -handleSize.height);
  widget.showMagnifierBarFunc(); // Call back to display magnifier function
  toolBarRecover = widget.hideToolbarFunc();
}
void showMagnifierBar() {
  assert(_magnifier == null);
  _magnifier = OverlayEntry(builder: _builderMagnifier);
  Overlay.of(context, rootOverlay: true, debugRequiredFor: debugRequiredFor)! .insert(_magnifier!) ; }Copy the code

Similarly, at the end of the drag to hide the magnifying glass, recreate the action bar to restore the display.

void _handleDragEnd(DragEndDetails details) { widget.hideMagnifierBarFunc(); if (toolBarRecover) { widget.showToolbarFunc(); toolBarRecover = false; } } void hideMagnifierBar() { if (_magnifier ! = null) { _magnifier! .remove(); _magnifier = null; }}Copy the code

The final result

Finally, the effect is as follows, by moving the cursor can display the magnifying glass function, release the gesture is the operation bar display recovery.

reference

  • How does the Flutter TextField input field elegantly prohibit the pop-up soft keyboard

  • Widget Magnifier