123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323 |
- // SPDX-License-Identifier: Unlicense OR MIT
- package material
- import (
- "image"
- "image/color"
- "math"
- "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.
- lengthEstPx := float32(lp.Length)
- elementLenEstPx := lengthEstPx / float32(elements)
- // Determine how much of the content is visible.
- listOffsetF := float32(lp.Offset)
- listOffsetL := float32(lp.OffsetLast)
- // Compute the location of the beginning of the viewport using estimated element size and known
- // pixel offsets.
- viewportStart := clamp1((float32(lp.First)*elementLenEstPx + listOffsetF) / lengthEstPx)
- viewportEnd := clamp1((float32(lp.First+lp.Count)*elementLenEstPx + listOffsetL) / lengthEstPx)
- viewportFraction := viewportEnd - viewportStart
- // Compute the expected visible proportion of the list content based solely on the ratio
- // of the visible size and the estimated total size.
- visiblePx := float32(majorAxisSize)
- visibleFraction := visiblePx / lengthEstPx
- // Compute the error between the two methods of determining the viewport and diffuse the
- // error on either end of the viewport based on how close we are to each end.
- err := visibleFraction - viewportFraction
- adjStart := viewportStart
- adjEnd := viewportEnd
- if viewportFraction < 1 {
- startShare := viewportStart / (1 - viewportFraction)
- endShare := (1 - viewportEnd) / (1 - viewportFraction)
- startErr := startShare * err
- endErr := endShare * err
- adjStart -= startErr
- adjEnd += endErr
- }
- return adjStart, adjEnd
- }
- // 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.Dp
- // 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.Dp
- // MinorWidth is the width of the scroll indicator across the minor axis.
- MinorWidth unit.Dp
- // 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.Dp
- }
- // 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: 2,
- MinorPadding: 2,
- },
- Indicator: ScrollIndicatorStyle{
- MajorMinLen: th.FingerSize,
- MinorWidth: 6,
- CornerRadius: 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() unit.Dp {
- return 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.Dp(s.Width())
- gtx.Constraints.Min = convert(gtx.Constraints.Min)
- gtx.Constraints.Max = gtx.Constraints.Min
- s.Scrollbar.Update(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
- }
- return layout.Background{}.Layout(gtx,
- func(gtx layout.Context) layout.Dimensions {
- // Lay out the draggable track underneath the scroll indicator.
- area := image.Rectangle{
- Max: gtx.Constraints.Min,
- }
- pointerArea := clip.Rect(area)
- defer pointerArea.Push(gtx.Ops).Pop()
- s.Scrollbar.AddDrag(gtx.Ops)
- // Stack a normal clickable area on top of the draggable area
- // to capture non-dragging clicks.
- defer pointer.PassOp{}.Push(gtx.Ops).Pop()
- defer pointerArea.Push(gtx.Ops).Pop()
- s.Scrollbar.AddTrack(gtx.Ops)
- paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
- return layout.Dimensions{Size: gtx.Constraints.Min}
- },
- func(gtx layout.Context) layout.Dimensions {
- 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 := gtx.Constraints.Min.X
- viewStart := int(math.Round(float64(viewportStart) * float64(trackLen)))
- viewEnd := int(math.Round(float64(viewportEnd) * float64(trackLen)))
- indicatorLen := max(viewEnd-viewStart, gtx.Dp(s.Indicator.MajorMinLen))
- if viewStart+indicatorLen > trackLen {
- viewStart = trackLen - indicatorLen
- }
- indicatorDims := axis.Convert(image.Point{
- X: indicatorLen,
- Y: gtx.Dp(s.Indicator.MinorWidth),
- })
- radius := gtx.Dp(s.Indicator.CornerRadius)
- // Lay out the indicator.
- offset := axis.Convert(image.Pt(viewStart, 0))
- defer op.Offset(offset).Push(gtx.Ops).Pop()
- paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{
- Rect: image.Rectangle{
- Max: indicatorDims,
- },
- SW: radius,
- NW: radius,
- NE: radius,
- SE: radius,
- }.Op(gtx.Ops))
- // Add the indicator pointer hit area.
- area := clip.Rect(image.Rectangle{Max: indicatorDims})
- defer pointer.PassOp{}.Push(gtx.Ops).Pop()
- defer area.Push(gtx.Ops).Pop()
- 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
- // Determine how much space the scrollbar occupies.
- barWidth := gtx.Dp(l.Width())
- if l.AnchorStrategy == Occupy {
- // 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
- if max.Y < 0 {
- max.Y = 0
- }
- min.Y -= barWidth
- if min.Y < 0 {
- min.Y = 0
- }
- 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)
- // layout.Direction respects the minimum, so ensure that the
- // scrollbar will be drawn on the correct edge even if the provided
- // layout.Context had a zero minimum constraint.
- gtx.Constraints.Min = listDims.Size
- if l.AnchorStrategy == Occupy {
- min := l.state.Axis.Convert(gtx.Constraints.Min)
- min.Y += barWidth
- gtx.Constraints.Min = l.state.Axis.Convert(min)
- }
- 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.
- l.state.List.ScrollBy(delta * float32(length))
- }
- if l.AnchorStrategy == Occupy {
- // Increase the width to account for the space occupied by the scrollbar.
- cross := l.state.Axis.Convert(listDims.Size)
- cross.Y += barWidth
- listDims.Size = l.state.Axis.Convert(cross)
- }
- return listDims
- }
|