Repository: flutter
Issue: Proposal to add Hit-Testing on non-closed paths or lines
Labels: framework,f: material design,f: gestures,c: proposal,team-design,triaged-design

Using `CustomPainter`s `void paint(Canvas canvas, Size size)`, it is easy to create shapes with `Path` and `canvas.drawPath`.
For a closed `Path` it is also easy to implement `bool hitTest(Offset position)` via `path.contains(position)`.

But, although `canvas.drawPath` can draw non-closed paths with some width using `Paint()..width = ...`, it is not easy (to my knowledge) to hit test those non-closed paths.
Only points that are directly on the path can be tested through `path.contains(position)`, since `width` is only known to `canvas.drawPath` via the paint.
But even if the cursor is seemingly directly over the path, small differences in the decimal places will mean that hit test will never return true via this approach.

It would be very helpful if flutter could expose the same `native` logic it uses to render the shape for hit testing.

If there is some other way this can be achieved easily, I would also be grateful for any feedback in that regard.

________

A small example demonstrating the issue:
<details>

The following is rendered but cant be hit tested easily:
![image](https://user-images.githubusercontent.com/24209580/147849605-656aadc0-f706-44e7-b987-957b038c6cb4.png)


```dart
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Colors.black,
        body: NewWidget(),
      ),
    );
  }
}

class NewWidget extends StatelessWidget {
  const NewWidget({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return CustomPaint(
      size: Size(100, 100),
      painter: LinePainter(
        start: Offset(0, 0),
        end: Offset(100, 100),
      ),
    );
  }
}

class LinePainter extends CustomPainter {
  final Offset start;
  final Offset end;

  final Path path = Path();

  LinePainter({
    required this.start,
    required this.end,
  }) {
    path.moveTo(start.dx, start.dy);
    path.lineTo(end.dx, end.dy);

    // print(path.contains(start)) // => true
  }

  final Paint edgePaint = Paint()
    ..color = Colors.white
    ..strokeWidth = 20
    ..style = PaintingStyle.stroke
    ..strokeCap = StrokeCap.round;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawPath(path, edgePaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

  @override
  bool hitTest(Offset position) {
    final hit = path.contains(position);
    print("$position: $hit"); // => false for everything that is not exactly on the path
    return hit;
  }
}
```
</details>

_____

I currently use the following workaround to construct a closed path with the desired width around the non-closed path.
This is probably sub-optimal and way slower than using similar `native` logic that is used for the rendering.

<details>

```dart

class LinePathPainter extends CustomPainter {
  final List<Offset> points;
  final Color color;
  final double width;

  late final LinePath linePath;

  LinePathPainter({
    required this.points,
    required this.color,
    required this.width,
  }) : linePath = LinePath(points: points, width: width);

  // : Paint Configuration
  late final Paint edgePaint = Paint()
    ..color = color
    ..strokeWidth = 1;

  @override
  void paint(Canvas canvas, Size size) {
    linePath.draw(canvas, edgePaint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;

  @override
  bool hitTest(Offset position) {
    final hit = linePath.hitTest(position);
    print("$position: $hit");
    return hit;
  }
}

/// A continuous line from [points.first] to [points.last] that is not closed and therefore does not have an internal area.
/// Useful for hit testing lines with some width.
///
/// This is an alternative to using [Path] directly, since non-closed [Path]s can not be hit tested.
/// (Only the exact points `p` on the path are `true == path.contains(p)`)
/// Creates a closed [Path] from [points.first] to [points.last] and back in reverse.
/// Both directions are offset from the direct path by half the [width] in either direction.
class LinePath {
  /// The width of the line.
  /// Determines the hitTest-able area of the line.
  final double width;

  /// Points from start to finish.
  final List<Offset> points;

  /// Cached length of [points]
  final int _length;

  late final Path path;

  LinePath({
    this.width = 1.0,
    required this.points,
  }) : _length = points.length {
    final List<Path> _linePaths = [];

    // Line segments as rects
    for (int i = 0; i < _length - 1; i++) {
      final p1 = points[i];
      final p2 = points[i + 1];
      _linePaths.add(_Helper.rectAroundVector(p1, p2, width / 2));
    }
    // for points
    Path _circlePath = Path();
    for (final _p in points) {
      _circlePath.addOval(Rect.fromCircle(center: _p, radius: width / 2));
    }

    // Union over all paths
    for (final _lp in _linePaths) {
      _circlePath = Path.combine(PathOperation.union, _circlePath, _lp);
    }

    path = _circlePath;
  }

  // TODO should paints width be reset?
  void draw(Canvas canvas, Paint paint) {
    canvas.drawPath(path, paint);
  }

  bool hitTest(Offset position) => path.contains(position);
}

class _Helper {
  /// Rotate a vector from [start] to [end] along [start].
  /// Returns rotated vector from [start] to returned value.
  ///
  /// Assumes TopLeft (0,0) and BottomRight (maxX;maxY)
  static Offset rotate90({
    required Offset end,
    bool clockwise = true,
    Offset start = Offset.zero,
  }) {
    final e0 = end - start;
    // (-y, x) is clockwise and (y, -x) is counterclockwise.
    return (clockwise ? Offset(-e0.dy, e0.dx) : Offset(e0.dy, -e0.dx)) + start;
  }

  /// For a vector from [p1] to [p2] returns [p1]s and [p2]s orthogonal points at [distance].
  /// Returns a closed [Path] with `[p1_above, p2_above, p2_below, p1_below]`
  static Path rectAroundVector(Offset p1, Offset p2, double distance) {
    // 1) normalize p2 to p1 as (0,0)
    final p2_zero = p2 - p1;

    // 2) get the orthogonal vector from p1 to above p1 (counter clock to vector)
    final p1_vec_above = rotate90(end: p2_zero, clockwise: false);

    // 4) scale the vectors to the desired distance
    final scale = distance / p1_vec_above.distance;
    final p1_vec_above_scaled = p1_vec_above * scale;

    // 5) get the points above and below p1 and p2
    final p1_above = p1 + p1_vec_above_scaled;
    final p1_below = p1 - p1_vec_above_scaled;
    final p2_above = p2 + p1_vec_above_scaled;
    final p2_below = p2 - p1_vec_above_scaled;

    return Path()
      ..moveTo(p1_above.dx, p1_above.dy)
      ..lineTo(p2_above.dx, p2_above.dy)
      ..lineTo(p2_below.dx, p2_below.dy)
      ..lineTo(p1_below.dx, p1_below.dy)
      ..close();
  }
}
```
</details>


_________
Thank you for taking the time, and a happy new year :)