A Flutter avatar Stack uses Clip.none so its camera button spills past the edge, but taps on the spilled part do nothing; why, and how do you fix it?
answer
- painting is not hit testing
- parent checks its own size first
- every ancestor must contain the point
- grow the Stack instead
- Padding around the avatar
basics
~20 sHit testing starts at the root and each RenderBox rejects points outside its own size before asking its children, so taps outside the Stack never reach the button. Clip.none only affects painting. Enlarge the Stack so the button lies inside it.
solid answer
~40 sPainting and hit testing are separate. `Clip.none` lets the camera button draw past the avatar's 72 by 72 box, but `RenderBox.hitTest` first checks that the pointer position is inside the box's own size. Only then does it call `hitTestChildren`. The Stack is 72 by 72, so a tap at (80, 80) is rejected before the button is ever asked. The framework's `Stack.clipBehavior` documentation says so explicitly. The fix is geometric: make the Stack big enough to contain the button, for example by wrapping the avatar in `Padding` (or a larger `SizedBox` with the avatar aligned) and positioning the button with non-negative insets. Every ancestor must contain the tap point too. `HitTestBehavior.opaque` on the button's `GestureDetector` does not help, because it only acts inside the detector's own bounds.
code
dart · 29 linesimport 'package:flutter/material.dart';
class EditableAvatar extends StatelessWidget {
const EditableAvatar({super.key, required this.image, required this.onChangePhoto});
final ImageProvider image;
final VoidCallback onChangePhoto;
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Padding grows the Stack to 96 x 96 so the button lies inside it.
Padding(
padding: const EdgeInsets.all(12),
child: CircleAvatar(radius: 36, backgroundImage: image),
),
Positioned(
right: 0,
bottom: 0,
child: IconButton.filled(
onPressed: onChangePhoto,
icon: const Icon(Icons.photo_camera, size: 18),
),
),
],
);
}
}go deeper
Recall that a widget drawn outside its parent is not tappable there, and that the fix is to make the parent bigger.
Explain that RenderBox.hitTest checks its own size before its children, so Clip.none changes painting but not hit testing.
Diagnose visible-but-untappable controls by checking every ancestor's bounds, and fix them by giving the Stack an honest size rather than spilling.
Build this into component review: interactive decorations must sit inside their widget's bounds, with tap-target sizing handled by the component itself.
## The symptom The profile screen shows a circular avatar with a small camera button at its bottom-right corner, half outside the circle. To make it visible the developer set `clipBehavior: Clip.none` on the `Stack` and used `Positioned(right: -12, bottom: -12)`. The button looks right. Taps on its inner half open the photo picker; taps on its outer half do nothing. This is one of the most common 'visible but not tappable' bugs in Flutter. ## How hit testing works When a pointer goes down, Flutter asks the render tree which objects are under that point. The walk is top-down: 1. The root checks the position and asks its children. 2. Each `RenderBox`'s `hitTest` first checks **`size.contains(position)`**: is the point inside my own rectangle? 3. Only if it is does the box call `hitTestChildren` and `hitTestSelf`; any box that reports a hit is added to the result. 4. Gesture recognisers for the boxes in the result then compete for the gesture. The important consequence: a child is only asked if **every ancestor** contained the point. Painting outside your parent does not change your parent's size. | Setting | Affects painting? | Affects hit testing? | |---|---|---| | `Stack.clipBehavior: Clip.none` | yes, overflow is drawn | no | | Negative `Positioned` insets | yes, child drawn outside | the outside part is unreachable | | `GestureDetector.behavior` | no | only within the detector's own bounds | | Enlarging the Stack | yes | yes, the point is now inside | The `Stack.clipBehavior` documentation states it directly: even with `Clip.none`, the Stack does not extend its hit-test region, and pointer events outside its bounds do not reach a child painted there. ## Diagnosing it - Tap the inner and outer halves of the control: if only the part inside the parent works, it is a bounds problem, not a gesture conflict. - Turn on the debug size paint (`debugPaintSizeEnabled`) to see each box's outline; the Stack's outline will stop at the avatar. - Check every ancestor, not just the Stack: a fixed-size parent such as `SizedBox(width: 72, height: 72)` has the same effect. ## Fixing it The reliable fix is to make the hit-testable area as large as the drawn area. 1. **Pad the size-defining child.** Wrap the `CircleAvatar` in `Padding(padding: EdgeInsets.all(12))`. The Stack becomes 96 by 96, and the button can use `Positioned(right: 0, bottom: 0)` with no negative insets. Keep `Clip.hardEdge`, since nothing overflows now. 2. **Or size the Stack explicitly.** Put the Stack in a `SizedBox(width: 96, height: 96)` and place the avatar with `Align` or `Positioned` inside it. 3. **Check the ancestors.** If the Stack sits in a `Row` slot or a list tile of fixed size, that slot must also include the button's area. Approaches that do not work: - `HitTestBehavior.opaque` or `translucent` on the button's detector: they decide whether the detector counts as hit **within its own bounds**; the ancestors have already rejected the point. - Raising the button's paint order or elevation: paint order does not change the Stack's size. - Increasing the button's own size: the extra area outside the Stack is just as unreachable. Overriding `hitTest` in a custom render object to accept points outside its size is possible, but it makes the widget behave differently from every other box, and it still needs every ancestor to pass the point. ## Why the fix is also better design Enlarging the Stack gives layout an honest size: neighbours are spaced from the full visual footprint, not from the avatar alone, so the button does not overlap a name label next to it. It also gives the control a larger tap target.
- In Flutter, if the Stack is enlarged but it sits inside a SizedBox(width: 72, height: 72), does the fix work?No. The `SizedBox` passes tight 72 by 72 constraints, so the Stack cannot actually grow, and even if a child painted beyond it, the `SizedBox`'s render box would reject points outside 72 by 72 before reaching the Stack. Every ancestor on the path must contain the tap point, so the outer size has to grow as well.
- Why doesn't HitTestBehavior.opaque on the camera button's GestureDetector fix the dead zone?`behavior` decides how the detector's own render box reports a hit for points inside its bounds, for example whether transparent areas count. The dead zone is outside the Stack, and the Stack's `hitTest` rejects the point before any child, including the detector, is asked. No setting on a descendant can widen an ancestor's hit area.
saying these in an interview costs you the question
- Clip.none makes the overflowing part of a child tappable.
- HitTestBehavior.opaque extends the tappable area beyond the parent.
- Painting a child last makes it receive all taps over its area.
- Hit testing works from the painted pixels, not from box sizes.
- Only the immediate parent's bounds matter for hit testing.