mirror of
https://git.sr.ht/~eliasnaur/gio
synced 2026-07-06 09:55:40 +00:00
widget{,/material}: add List types with scrollbars
To use these lists instead of layout.List, callers simply need to
change declarations of layout.List to widget.List, and to change
calls to layout.List.Layout to material.List(th,&list).Layout.
So this:
var list layout.List
list.Layout(gtx, 10, func(gtx C, index int) D {
return material.Body1(th, fmt.Sprintf("%d", index)).Layout(gtx)
})
Becomes:
var list widget.List
material.List(th, &list).Layout(gtx, 10, func(gtx C, index int) D {
return material.Body1(th, fmt.Sprintf("%d", index)).Layout(gtx)
})
Naturally, the material.ListStyle type supports tweaking the scrollbar's
appearance and behavior.
Signed-off-by: Chris Waldon <christopher.waldon.dev@gmail.com>
This commit is contained in:
+113
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package widget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"gioui.org/gesture"
|
||||||
|
"gioui.org/io/key"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scrollbar holds the persistent state for an area that can
|
||||||
|
// display a scrollbar. In particular, it tracks the position of a
|
||||||
|
// viewport along a one-dimensional region of content. The viewport's
|
||||||
|
// position can be adjusted by drag operations along the display area,
|
||||||
|
// or by clicks within the display area.
|
||||||
|
//
|
||||||
|
// Scrollbar additionally detects when a scroll indicator region is
|
||||||
|
// hovered.
|
||||||
|
type Scrollbar struct {
|
||||||
|
track, indicator gesture.Click
|
||||||
|
drag gesture.Drag
|
||||||
|
delta float32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout updates the internal state of the scrollbar based on events
|
||||||
|
// since the previous call to Layout. The provided axis will be used to
|
||||||
|
// normalize input event coordinates and constraints into an axis-
|
||||||
|
// independent format. viewportStart is the position of the beginning
|
||||||
|
// of the scrollable viewport relative to the underlying content expressed
|
||||||
|
// as a value in the range [0,1]. viewportEnd is the position of the end
|
||||||
|
// of the viewport relative to the underlying content, also expressed
|
||||||
|
// as a value in the range [0,1]. For example, if viewportStart is 0.25
|
||||||
|
// and viewportEnd is .5, the viewport described by the scrollbar is
|
||||||
|
// currently showing the second quarter of the underlying content.
|
||||||
|
func (s *Scrollbar) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
|
||||||
|
// Calculate the length of the major axis of the scrollbar. This is
|
||||||
|
// the length of the track within which pointer events occur, and is
|
||||||
|
// used to scale those interactions.
|
||||||
|
trackHeight := float32(axis.Convert(gtx.Constraints.Max).X)
|
||||||
|
s.delta = 0
|
||||||
|
|
||||||
|
// Jump to a click in the track.
|
||||||
|
for _, event := range s.track.Events(gtx) {
|
||||||
|
if event.Type != gesture.TypeClick ||
|
||||||
|
event.Modifiers != key.Modifiers(0) ||
|
||||||
|
event.NumClicks > 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pos := axis.Convert(image.Point{
|
||||||
|
X: int(event.Position.X),
|
||||||
|
Y: int(event.Position.Y),
|
||||||
|
})
|
||||||
|
normalizedPos := float32(pos.X) / trackHeight
|
||||||
|
s.delta += normalizedPos - viewportStart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset to account for any drags.
|
||||||
|
for _, event := range s.drag.Events(gtx.Metric, gtx, gesture.Axis(axis)) {
|
||||||
|
if event.Type != pointer.Drag {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dragOffset := axis.FConvert(event.Position).X
|
||||||
|
normalizedDragOffset := dragOffset / trackHeight
|
||||||
|
s.delta += normalizedDragOffset - viewportStart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process events from the indicator so that hover is
|
||||||
|
// detected properly.
|
||||||
|
_ = s.indicator.Events(gtx)
|
||||||
|
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddTrack configures the track click listener for the scrollbar to use
|
||||||
|
// the most recently added pointer.AreaOp.
|
||||||
|
func (s *Scrollbar) AddTrack(ops *op.Ops) {
|
||||||
|
s.track.Add(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddIndicator configures the indicator click listener for the scrollbar to use
|
||||||
|
// the most recently added pointer.AreaOp.
|
||||||
|
func (s *Scrollbar) AddIndicator(ops *op.Ops) {
|
||||||
|
s.indicator.Add(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDrag configures the drag listener for the scrollbar to use
|
||||||
|
// the most recently added pointer.AreaOp.
|
||||||
|
func (s *Scrollbar) AddDrag(ops *op.Ops) {
|
||||||
|
s.drag.Add(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndicatorHovered returns whether the scroll indicator is currently being
|
||||||
|
// hovered by the pointer.
|
||||||
|
func (s *Scrollbar) IndicatorHovered() bool {
|
||||||
|
return s.indicator.Hovered()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollDistance returns the normalized distance that the scrollbar
|
||||||
|
// moved during the last call to Layout as a value in the range [-1,1].
|
||||||
|
func (s *Scrollbar) ScrollDistance() float32 {
|
||||||
|
return s.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
// List holds the persistent state for a layout.List that has a
|
||||||
|
// scrollbar attached.
|
||||||
|
type List struct {
|
||||||
|
Scrollbar
|
||||||
|
layout.List
|
||||||
|
}
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// SPDX-License-Identifier: Unlicense OR MIT
|
||||||
|
|
||||||
|
package material
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"gioui.org/f32"
|
||||||
|
"gioui.org/io/pointer"
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
|
"gioui.org/op/clip"
|
||||||
|
"gioui.org/op/paint"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fromListPosition converts a layout.Position into two floats representing
|
||||||
|
// the location of the viewport on the underlying content. It needs to know
|
||||||
|
// the number of elements in the list and the major-axis size of the list
|
||||||
|
// in order to do this. The returned values will be in the range [0,1], and
|
||||||
|
// start will be less than or equal to end.
|
||||||
|
func fromListPosition(lp layout.Position, elements int, majorAxisSize int) (start, end float32) {
|
||||||
|
// Approximate the size of the scrollable content.
|
||||||
|
lengthPx := float32(lp.Length)
|
||||||
|
meanElementHeight := lengthPx / float32(elements)
|
||||||
|
|
||||||
|
// Determine how much of the content is visible.
|
||||||
|
listOffsetF := float32(lp.Offset)
|
||||||
|
visiblePx := float32(majorAxisSize)
|
||||||
|
visibleFraction := visiblePx / lengthPx
|
||||||
|
|
||||||
|
// Compute the location of the beginning of the viewport.
|
||||||
|
viewportStart := (float32(lp.First)*meanElementHeight + listOffsetF) / lengthPx
|
||||||
|
|
||||||
|
return viewportStart, viewportStart + visibleFraction
|
||||||
|
}
|
||||||
|
|
||||||
|
// rangeIsScrollable returns whether the viewport described by start and end
|
||||||
|
// is smaller than the underlying content (such that it can be scrolled).
|
||||||
|
// start and end are expected to each be in the range [0,1], and start
|
||||||
|
// must be less than or equal to end.
|
||||||
|
func rangeIsScrollable(start, end float32) bool {
|
||||||
|
return end-start < 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollTrackStyle configures the presentation of a track for a scroll area.
|
||||||
|
type ScrollTrackStyle struct {
|
||||||
|
// MajorPadding and MinorPadding along the major and minor axis of the
|
||||||
|
// scrollbar's track. This is used to keep the scrollbar from touching
|
||||||
|
// the edges of the content area.
|
||||||
|
MajorPadding, MinorPadding unit.Value
|
||||||
|
// Color of the track background.
|
||||||
|
Color color.NRGBA
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollIndicatorStyle configures the presentation of a scroll indicator.
|
||||||
|
type ScrollIndicatorStyle struct {
|
||||||
|
// MajorMinLen is the smallest that the scroll indicator is allowed to
|
||||||
|
// be along the major axis.
|
||||||
|
MajorMinLen unit.Value
|
||||||
|
// MinorWidth is the width of the scroll indicator across the minor axis.
|
||||||
|
MinorWidth unit.Value
|
||||||
|
// Color and HoverColor are the normal and hovered colors of the scroll
|
||||||
|
// indicator.
|
||||||
|
Color, HoverColor color.NRGBA
|
||||||
|
// CornerRadius is the corner radius of the rectangular indicator. 0
|
||||||
|
// will produce square corners. 0.5*MinorWidth will produce perfectly
|
||||||
|
// round corners.
|
||||||
|
CornerRadius unit.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollbarStyle configures the presentation of a scrollbar.
|
||||||
|
type ScrollbarStyle struct {
|
||||||
|
Scrollbar *widget.Scrollbar
|
||||||
|
Track ScrollTrackStyle
|
||||||
|
Indicator ScrollIndicatorStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollbar configures the presentation of a scrollbar using the provided
|
||||||
|
// theme and state.
|
||||||
|
func Scrollbar(th *Theme, state *widget.Scrollbar) ScrollbarStyle {
|
||||||
|
lightFg := th.Palette.Fg
|
||||||
|
lightFg.A = 150
|
||||||
|
darkFg := lightFg
|
||||||
|
darkFg.A = 200
|
||||||
|
|
||||||
|
return ScrollbarStyle{
|
||||||
|
Scrollbar: state,
|
||||||
|
Track: ScrollTrackStyle{
|
||||||
|
MajorPadding: unit.Dp(2),
|
||||||
|
MinorPadding: unit.Dp(2),
|
||||||
|
},
|
||||||
|
Indicator: ScrollIndicatorStyle{
|
||||||
|
MajorMinLen: unit.Dp(8),
|
||||||
|
MinorWidth: unit.Dp(6),
|
||||||
|
CornerRadius: unit.Dp(3),
|
||||||
|
Color: lightFg,
|
||||||
|
HoverColor: darkFg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Width returns the minor axis width of the scrollbar in its current
|
||||||
|
// configuration (taking padding for the scroll track into account).
|
||||||
|
func (s ScrollbarStyle) Width(metric unit.Metric) unit.Value {
|
||||||
|
return unit.Add(metric, s.Indicator.MinorWidth, s.Track.MinorPadding, s.Track.MinorPadding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout the scrollbar.
|
||||||
|
func (s ScrollbarStyle) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
|
||||||
|
if !rangeIsScrollable(viewportStart, viewportEnd) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set minimum constraints in an axis-independent way, then convert to
|
||||||
|
// the correct representation for the current axis.
|
||||||
|
convert := axis.Convert
|
||||||
|
maxMajorAxis := convert(gtx.Constraints.Max).X
|
||||||
|
gtx.Constraints.Min.X = maxMajorAxis
|
||||||
|
gtx.Constraints.Min.Y = gtx.Px(s.Width(gtx.Metric))
|
||||||
|
gtx.Constraints.Min = convert(gtx.Constraints.Min)
|
||||||
|
gtx.Constraints.Max = gtx.Constraints.Min
|
||||||
|
|
||||||
|
s.Scrollbar.Layout(gtx, axis, viewportStart, viewportEnd)
|
||||||
|
|
||||||
|
// Darken indicator if hovered.
|
||||||
|
if s.Scrollbar.IndicatorHovered() {
|
||||||
|
s.Indicator.Color = s.Indicator.HoverColor
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.layout(gtx, axis, viewportStart, viewportEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout the scroll track and indicator.
|
||||||
|
func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
|
||||||
|
inset := layout.Inset{
|
||||||
|
Top: s.Track.MajorPadding,
|
||||||
|
Bottom: s.Track.MajorPadding,
|
||||||
|
Left: s.Track.MinorPadding,
|
||||||
|
Right: s.Track.MinorPadding,
|
||||||
|
}
|
||||||
|
if axis == layout.Horizontal {
|
||||||
|
inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom
|
||||||
|
}
|
||||||
|
// Capture the outer constraints because layout.Stack will reset
|
||||||
|
// the minimum to zero.
|
||||||
|
outerConstraints := gtx.Constraints
|
||||||
|
|
||||||
|
return layout.Stack{}.Layout(gtx,
|
||||||
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
// Lay out the draggable track underneath the scroll indicator.
|
||||||
|
area := image.Rectangle{
|
||||||
|
Max: gtx.Constraints.Min,
|
||||||
|
}
|
||||||
|
pointerArea := pointer.Rect(area)
|
||||||
|
pointerArea.Add(gtx.Ops)
|
||||||
|
s.Scrollbar.AddDrag(gtx.Ops)
|
||||||
|
|
||||||
|
// Stack a normal clickable area on top of the draggable area
|
||||||
|
// to capture non-dragging clicks.
|
||||||
|
saved := op.Save(gtx.Ops)
|
||||||
|
pointer.PassOp{Pass: true}.Add(gtx.Ops)
|
||||||
|
pointerArea.Add(gtx.Ops)
|
||||||
|
s.Scrollbar.AddTrack(gtx.Ops)
|
||||||
|
saved.Load()
|
||||||
|
|
||||||
|
paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}),
|
||||||
|
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
gtx.Constraints = outerConstraints
|
||||||
|
return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
// Use axis-independent constraints.
|
||||||
|
gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min)
|
||||||
|
gtx.Constraints.Max = axis.Convert(gtx.Constraints.Max)
|
||||||
|
|
||||||
|
// Compute the pixel size and position of the scroll indicator within
|
||||||
|
// the track.
|
||||||
|
trackLen := float32(gtx.Constraints.Min.X)
|
||||||
|
viewStart := viewportStart * trackLen
|
||||||
|
viewEnd := viewportEnd * trackLen
|
||||||
|
indicatorLen := unit.Max(gtx.Metric, unit.Px(viewEnd-viewStart), s.Indicator.MajorMinLen)
|
||||||
|
indicatorDims := axis.Convert(image.Point{
|
||||||
|
X: gtx.Px(indicatorLen),
|
||||||
|
Y: gtx.Px(s.Indicator.MinorWidth),
|
||||||
|
})
|
||||||
|
indicatorDimsF := layout.FPt(indicatorDims)
|
||||||
|
radius := float32(gtx.Px(s.Indicator.CornerRadius))
|
||||||
|
|
||||||
|
// Lay out the indicator.
|
||||||
|
defer op.Save(gtx.Ops).Load()
|
||||||
|
offset := axis.Convert(image.Pt(int(viewStart), 0))
|
||||||
|
op.Offset(layout.FPt(offset)).Add(gtx.Ops)
|
||||||
|
paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{
|
||||||
|
Rect: f32.Rectangle{
|
||||||
|
Max: indicatorDimsF,
|
||||||
|
},
|
||||||
|
SW: radius,
|
||||||
|
NW: radius,
|
||||||
|
NE: radius,
|
||||||
|
SE: radius,
|
||||||
|
}.Op(gtx.Ops))
|
||||||
|
|
||||||
|
// Add the indicator pointer hit area.
|
||||||
|
pointer.PassOp{Pass: true}.Add(gtx.Ops)
|
||||||
|
pointer.Rect(image.Rectangle{Max: indicatorDims}).Add(gtx.Ops)
|
||||||
|
s.Scrollbar.AddIndicator(gtx.Ops)
|
||||||
|
|
||||||
|
return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)}
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnchorStrategy defines a means of attaching a scrollbar to content.
|
||||||
|
type AnchorStrategy uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Occupy reserves space for the scrollbar, making the underlying
|
||||||
|
// content region smaller on one axis.
|
||||||
|
Occupy AnchorStrategy = iota
|
||||||
|
// Overlay causes the scrollbar to float atop the content without
|
||||||
|
// occupying any space. Content in the underlying area can be occluded
|
||||||
|
// by the scrollbar.
|
||||||
|
Overlay
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListStyle configures the presentation of a layout.List with a scrollbar.
|
||||||
|
type ListStyle struct {
|
||||||
|
state *widget.List
|
||||||
|
ScrollbarStyle
|
||||||
|
AnchorStrategy
|
||||||
|
}
|
||||||
|
|
||||||
|
// List constructs a ListStyle using the provided theme and state.
|
||||||
|
func List(th *Theme, state *widget.List) ListStyle {
|
||||||
|
return ListStyle{
|
||||||
|
state: state,
|
||||||
|
ScrollbarStyle: Scrollbar(th, &state.Scrollbar),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout the list and its scrollbar.
|
||||||
|
func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement) layout.Dimensions {
|
||||||
|
originalConstraints := gtx.Constraints
|
||||||
|
|
||||||
|
if l.AnchorStrategy == Occupy {
|
||||||
|
// Determine how much space the scrollbar occupies.
|
||||||
|
barWidth := gtx.Px(l.Width(gtx.Metric))
|
||||||
|
|
||||||
|
// Reserve space for the scrollbar using the gtx constraints.
|
||||||
|
max := l.state.Axis.Convert(gtx.Constraints.Max)
|
||||||
|
min := l.state.Axis.Convert(gtx.Constraints.Min)
|
||||||
|
max.Y -= barWidth
|
||||||
|
min.Y -= barWidth
|
||||||
|
gtx.Constraints.Max = l.state.Axis.Convert(max)
|
||||||
|
gtx.Constraints.Min = l.state.Axis.Convert(min)
|
||||||
|
}
|
||||||
|
|
||||||
|
listDims := l.state.List.Layout(gtx, length, w)
|
||||||
|
gtx.Constraints = originalConstraints
|
||||||
|
|
||||||
|
// Draw the scrollbar.
|
||||||
|
anchoring := layout.E
|
||||||
|
if l.state.Axis == layout.Horizontal {
|
||||||
|
anchoring = layout.S
|
||||||
|
}
|
||||||
|
majorAxisSize := l.state.Axis.Convert(listDims.Size).X
|
||||||
|
start, end := fromListPosition(l.state.Position, length, majorAxisSize)
|
||||||
|
anchoring.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return l.ScrollbarStyle.Layout(gtx, l.state.Axis, start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
if delta := l.state.ScrollDistance(); delta != 0 {
|
||||||
|
// Handle any changes to the list position as a result of user interaction
|
||||||
|
// with the scrollbar.
|
||||||
|
deltaPx := int(math.Round(float64(float32(l.state.Position.Length) * delta)))
|
||||||
|
l.state.List.Position.Offset += deltaPx
|
||||||
|
}
|
||||||
|
|
||||||
|
return listDims
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user