Skip to content
Merged
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
89 changes: 89 additions & 0 deletions apps/gallery/lib/components/ui/_utils/tap_target.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import 'dart:math' as math;

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

/// Guarantees a minimum interactive hit area around [child] **without enlarging
/// the child's painted size** — the Material-free analog of Material's tap-target
/// padding (Apple HIG 44pt / Android 48dp; flutterbits is mobile-first, charter
/// §4: touch targets ≥ ~44px).
///
/// The child paints at its natural size, centered; the extra area is transparent
/// and only extends layout + hit-testing. Use it to wrap the *gesture* layer of an
/// interactive component so a visually-small control (an icon button, a compact
/// `sm` button) is still comfortably tappable.
///
/// This is a shared `_utils` helper, **not** copy-pasted per component — every
/// interactive component (Button, IconButton, Switch, Checkbox…) wraps its hit
/// layer in one of these, so the touch-target rule is solved once.
class MinTapTarget extends SingleChildRenderObjectWidget {
/// Wraps [child] so the laid-out + hit-tested area is at least [minSize] on
/// each axis (default 48 — Android's `mdpi` target, which also satisfies HIG 44).
const MinTapTarget({super.key, this.minSize = 48.0, required Widget super.child})
: assert(minSize >= 0, 'flutterbits: minSize must be >= 0 (got $minSize).');

/// The minimum width and height of the hit area, in logical px.
final double minSize;

@override
RenderObject createRenderObject(BuildContext context) => _RenderMinTapTarget(minSize);

@override
void updateRenderObject(BuildContext context, RenderObject renderObject) {
(renderObject as _RenderMinTapTarget).minSize = minSize;
}
}

class _RenderMinTapTarget extends RenderShiftedBox {
_RenderMinTapTarget(this._minSize) : super(null);

double _minSize;
set minSize(double value) {
if (value != _minSize) {
_minSize = value;
markNeedsLayout();
}
}

Size _computeSize(BoxConstraints constraints, ChildLayouter layoutChild) {
final RenderBox? child = this.child;
if (child == null) {
return constraints.constrain(Size(_minSize, _minSize));
}
// Loosen so the child takes its natural size; we only *expand* around it.
final Size childSize = layoutChild(child, constraints.loosen());
return constraints.constrain(
Size(math.max(childSize.width, _minSize), math.max(childSize.height, _minSize)),
);
}

@override
double computeMinIntrinsicWidth(double height) =>
math.max(super.computeMinIntrinsicWidth(height), _minSize);

@override
double computeMaxIntrinsicWidth(double height) =>
math.max(super.computeMaxIntrinsicWidth(height), _minSize);

@override
double computeMinIntrinsicHeight(double width) =>
math.max(super.computeMinIntrinsicHeight(width), _minSize);

@override
double computeMaxIntrinsicHeight(double width) =>
math.max(super.computeMaxIntrinsicHeight(width), _minSize);

@override
Size computeDryLayout(BoxConstraints constraints) =>
_computeSize(constraints, ChildLayoutHelper.dryLayoutChild);

@override
void performLayout() {
size = _computeSize(constraints, ChildLayoutHelper.layoutChild);
final RenderBox? child = this.child;
if (child != null) {
final BoxParentData childParentData = child.parentData! as BoxParentData;
childParentData.offset = Alignment.center.alongOffset(size - child.size as Offset);
}
}
}
Loading
Loading