core/scrollview/widget.go
Functions
func Children
func (w *Widget) Children() []widget.Widget {
if w.content == nil {
return nil
}
return []widget.Widget{w.content}
}
func Content
Content returns the scroll view's content widget.
func (w *Widget) Content() widget.Widget {
return w.content
}
func ContentSize
ContentSize returns the measured content size.
func (w *Widget) ContentSize() geometry.Size {
return w.contentSize
}
func Draw
Draw renders the scroll view to the canvas.
Drawing order:
1. Push clip to viewport bounds
2. Push transform for scroll offset
3. Draw content
4. Pop transform
5. Pop clip
6. Draw scrollbar(s) on top
func (w *Widget) Draw(ctx widget.Context, canvas widget.Canvas) {
bounds := w.Bounds()
if bounds.IsEmpty() {
return
}
// Process track repeat (page-scroll while mouse held on track).
w.tickTrackRepeat(ctx)
scrollX := w.cfg.ResolvedScrollX()
scrollY := w.cfg.ResolvedScrollY()
// Clip content to viewport.
canvas.PushClip(bounds)
canvas.PushTransform(geometry.Pt(bounds.Min.X-scrollX, bounds.Min.Y-scrollY))
if w.content != nil {
widget.StampScreenOrigin(w.content, canvas)
w.content.Draw(ctx, canvas)
}
canvas.PopTransform()
canvas.PopClip()
// Draw scrollbar(s) on top.
w.paintScrollbars(canvas)
}
func Event
Event handles an input event and returns true if consumed.
Event coordinates are transformed from parent space to content space before
dispatching to the content widget. This is the inverse of the Draw transform
(PushTransform with scroll offset), following the universal GUI framework
pattern used by Flutter, Qt, WPF, and Gio.
Scrollbar interactions are NOT transformed because scrollbars are drawn
on top of the viewport in parent-space coordinates.
func (w *Widget) Event(ctx widget.Context, e event.Event) bool {
// Handle scrollbar interactions FIRST so the thumb drag always works,
// even when content would consume the same mouse event (e.g., item click).
if me, ok := e.(*event.MouseEvent); ok {
if w.dragging != dragNone || w.isOnScrollbar(me.Position) {
return handleEvent(w, ctx, e)
}
}
// Only dispatch to content if the event position is within the viewport.
// This prevents content from receiving events when the mouse is outside.
// Skip this check if bounds haven't been set yet (empty rect).
if !w.isEventInsideViewport(e) {
return handleEvent(w, ctx, e)
}
// Transform event coordinates to content space and dispatch to content.
if w.content != nil {
contentEvent := w.transformToContentSpace(e)
if consumed := w.content.Event(ctx, contentEvent); consumed {
return true
}
}
return handleEvent(w, ctx, e)
}
func IsFocusable
IsFocusable reports whether the scroll view can currently receive focus.
func (w *Widget) IsFocusable() bool {
return w.IsVisible() && w.IsEnabled()
}
func IsViewportClip
IsViewportClip tells the dirty Collector that this widget acts as a
viewport boundary (Flutter RenderViewport pattern). The Collector adds
this widget's own bounds as the dirty region and does NOT recurse into
children — scroll content may have bounds exceeding the viewport.
func (w *Widget) IsViewportClip() bool { return true }
func Layout
Layout calculates the scroll view's size and measures its content.
The viewport is constrained to the parent's constraints. The content
is measured with unconstrained dimensions along the scroll axis to
determine its natural size.
func (w *Widget) Layout(ctx widget.Context, constraints geometry.Constraints) geometry.Size {
// The viewport fills the available space.
w.viewportSize = constraints.Biggest()
if w.viewportSize.Width <= 0 || w.viewportSize.Height <= 0 {
w.viewportSize = constraints.Constrain(geometry.Sz(defaultViewportWidth, defaultViewportHeight))
}
if w.content == nil {
return w.viewportSize
}
// Build content constraints: unconstrained along scroll axes.
contentConstraints := w.buildContentConstraints()
// Measure content.
w.contentSize = w.content.Layout(ctx, contentConstraints)
// Set content bounds at (0, 0) with its natural size.
if setter, ok := w.content.(interface{ SetBounds(geometry.Rect) }); ok {
setter.SetBounds(geometry.NewRect(0, 0, w.contentSize.Width, w.contentSize.Height))
}
return w.viewportSize
}
func Mount
Mount creates signal bindings for push-based invalidation.
Implements [widget.Lifecycle].
func (w *Widget) Mount(ctx widget.Context) {
sched := ctx.Scheduler()
if sched == nil {
return
}
if w.cfg.readonlyScrollXSignal != nil {
b := state.BindToScheduler(w.cfg.readonlyScrollXSignal, w, sched)
w.AddBinding(b)
} else if w.cfg.scrollXSignal != nil {
b := state.BindToScheduler(w.cfg.scrollXSignal, w, sched)
w.AddBinding(b)
}
if w.cfg.readonlyScrollYSignal != nil {
b := state.BindToScheduler(w.cfg.readonlyScrollYSignal, w, sched)
w.AddBinding(b)
} else if w.cfg.scrollYSignal != nil {
b := state.BindToScheduler(w.cfg.scrollYSignal, w, sched)
w.AddBinding(b)
}
}
func New
New creates a new scroll view Widget wrapping the given content widget.
The returned widget is visible, enabled, and focusable by default.
The default direction is [Vertical] with [ScrollbarAuto] visibility.
func New(content widget.Widget, opts ...Option) *Widget {
w := &Widget{
content: content,
painter: DefaultPainter{},
}
w.SetVisible(true)
w.SetEnabled(true)
// Set parent so dirty propagation and viewport clipping work correctly.
// Android pattern: invalidateChildInParent() clips dirty rect to parent bounds.
// Without this, content.Parent()=nil and clipToParentViewport cannot clip
// content bounds (e.g. 36000px) to viewport bounds.
if setter, ok := content.(interface{ SetParent(widget.Widget) }); ok {
setter.SetParent(w)
}
for _, opt := range opts {
opt(&w.cfg)
}
if w.cfg.painter != nil {
w.painter = w.cfg.painter
}
return w
}
func Padding
Padding sets the content padding. Returns the widget for method chaining.
func (w *Widget) Padding(_ float32) *Widget {
// Reserved for future use. Currently a no-op.
return w
}
func ScrollOffset
ScrollOffset returns the current scroll offset.
func (w *Widget) ScrollOffset() (x, y float32) {
return w.cfg.ResolvedScrollX(), w.cfg.ResolvedScrollY()
}
func ScrollbarInset
ScrollbarInset returns the width reserved for visible scrollbars.
When the vertical scrollbar is shown, this is the total scrollbar width
(track + padding). Otherwise zero.
func (w *Widget) ScrollbarInset() float32 {
if w.shouldShowVScrollbar() {
return scrollbarWidth + scrollbarPadding*2
}
return 0
}
func Unmount
Unmount is called when the scroll view is removed from the widget tree.
Implements [widget.Lifecycle].
func (w *Widget) Unmount() {
// Bindings are cleaned up automatically by WidgetBase.CleanupBindings().
}
func ViewportSize
ViewportSize returns the current viewport size.
func (w *Widget) ViewportSize() geometry.Size {
return w.viewportSize
}
Structs
type Widget struct
Widget implements a scrollable container that clips and translates its
content child widget.
A scroll view is created with [New] using functional options:
sv := scrollview.New(content,
scrollview.DirectionOpt(scrollview.Vertical),
scrollview.OnScroll(handleScroll),
)
type Widget struct {
widget.WidgetBase
cfg config
content widget.Widget
painter Painter
// Cached layout measurements.
contentSize geometry.Size
viewportSize geometry.Size
// Interaction state.
hovered bool
dragging dragAxis
dragStart geometry.Point
dragScrollStart float32
// Track repeat state: page-scroll repeats while mouse is held on the track.
trackRepeat trackRepeatState
}
Children returns the content widget as the single child.