gioui.org@v0.6.1-0.20240506124620-7a9ce51988ce/widget/list.go (about)

     1  // SPDX-License-Identifier: Unlicense OR MIT
     2  
     3  package widget
     4  
     5  import (
     6  	"image"
     7  
     8  	"gioui.org/gesture"
     9  	"gioui.org/io/key"
    10  	"gioui.org/io/pointer"
    11  	"gioui.org/layout"
    12  	"gioui.org/op"
    13  )
    14  
    15  // Scrollbar holds the persistent state for an area that can
    16  // display a scrollbar. In particular, it tracks the position of a
    17  // viewport along a one-dimensional region of content. The viewport's
    18  // position can be adjusted by drag operations along the display area,
    19  // or by clicks within the display area.
    20  //
    21  // Scrollbar additionally detects when a scroll indicator region is
    22  // hovered.
    23  type Scrollbar struct {
    24  	track, indicator gesture.Click
    25  	drag             gesture.Drag
    26  	delta            float32
    27  
    28  	dragging   bool
    29  	oldDragPos float32
    30  }
    31  
    32  // Update updates the internal state of the scrollbar based on events
    33  // since the previous call to Update. The provided axis will be used to
    34  // normalize input event coordinates and constraints into an axis-
    35  // independent format. viewportStart is the position of the beginning
    36  // of the scrollable viewport relative to the underlying content expressed
    37  // as a value in the range [0,1]. viewportEnd is the position of the end
    38  // of the viewport relative to the underlying content, also expressed
    39  // as a value in the range [0,1]. For example, if viewportStart is 0.25
    40  // and viewportEnd is .5, the viewport described by the scrollbar is
    41  // currently showing the second quarter of the underlying content.
    42  func (s *Scrollbar) Update(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) {
    43  	// Calculate the length of the major axis of the scrollbar. This is
    44  	// the length of the track within which pointer events occur, and is
    45  	// used to scale those interactions.
    46  	trackHeight := float32(axis.Convert(gtx.Constraints.Max).X)
    47  	s.delta = 0
    48  
    49  	centerOnClick := func(normalizedPos float32) {
    50  		// When the user clicks on the scrollbar we center on that point, respecting the limits of the beginning and end
    51  		// of the scrollbar.
    52  		//
    53  		// Centering gives a consistent experience whether the user clicks above or below the indicator.
    54  		target := normalizedPos - (viewportEnd-viewportStart)/2
    55  		s.delta += target - viewportStart
    56  		if s.delta < -viewportStart {
    57  			s.delta = -viewportStart
    58  		} else if s.delta > 1-viewportEnd {
    59  			s.delta = 1 - viewportEnd
    60  		}
    61  	}
    62  
    63  	// Jump to a click in the track.
    64  	for {
    65  		event, ok := s.track.Update(gtx.Source)
    66  		if !ok {
    67  			break
    68  		}
    69  		if event.Kind != gesture.KindClick ||
    70  			event.Modifiers != key.Modifiers(0) ||
    71  			event.NumClicks > 1 {
    72  			continue
    73  		}
    74  		pos := axis.Convert(image.Point{
    75  			X: int(event.Position.X),
    76  			Y: int(event.Position.Y),
    77  		})
    78  		normalizedPos := float32(pos.X) / trackHeight
    79  		// Clicking on the indicator should not jump to that position on the track. The user might've just intended to
    80  		// drag and changed their mind.
    81  		if !(normalizedPos >= viewportStart && normalizedPos <= viewportEnd) {
    82  			centerOnClick(normalizedPos)
    83  		}
    84  	}
    85  
    86  	// Offset to account for any drags.
    87  	for {
    88  		event, ok := s.drag.Update(gtx.Metric, gtx.Source, gesture.Axis(axis))
    89  		if !ok {
    90  			break
    91  		}
    92  		switch event.Kind {
    93  		case pointer.Drag:
    94  		case pointer.Release, pointer.Cancel:
    95  			s.dragging = false
    96  			continue
    97  		default:
    98  			continue
    99  		}
   100  		dragOffset := axis.FConvert(event.Position).X
   101  		// The user can drag outside of the constraints, or even the window. Limit dragging to within the scrollbar.
   102  		if dragOffset < 0 {
   103  			dragOffset = 0
   104  		} else if dragOffset > trackHeight {
   105  			dragOffset = trackHeight
   106  		}
   107  		normalizedDragOffset := dragOffset / trackHeight
   108  
   109  		if !s.dragging {
   110  			s.dragging = true
   111  			s.oldDragPos = normalizedDragOffset
   112  
   113  			if normalizedDragOffset < viewportStart || normalizedDragOffset > viewportEnd {
   114  				// The user started dragging somewhere on the track that isn't covered by the indicator. Consider this a
   115  				// click in addition to a drag and jump to the clicked point.
   116  				//
   117  				// TODO(dh): this isn't perfect. We only get the pointer.Drag event once the user has actually dragged,
   118  				// which means that if the user presses the mouse button and neither releases it nor drags it, nothing
   119  				// will happen.
   120  				pos := axis.Convert(image.Point{
   121  					X: int(event.Position.X),
   122  					Y: int(event.Position.Y),
   123  				})
   124  				normalizedPos := float32(pos.X) / trackHeight
   125  				centerOnClick(normalizedPos)
   126  			}
   127  		} else {
   128  			s.delta += normalizedDragOffset - s.oldDragPos
   129  
   130  			if viewportStart+s.delta < 0 {
   131  				// Adjust normalizedDragOffset - and thus the future s.oldDragPos - so that futile dragging up has to be
   132  				// countered with dragging down again. Otherwise, dragging up would have no effect, but dragging down would
   133  				// immediately start scrolling. We want the user to undo their ineffective drag first.
   134  				normalizedDragOffset -= viewportStart + s.delta
   135  				// Limit s.delta to the maximum amount scrollable
   136  				s.delta = -viewportStart
   137  			} else if viewportEnd+s.delta > 1 {
   138  				normalizedDragOffset += (1 - viewportEnd) - s.delta
   139  				s.delta = 1 - viewportEnd
   140  			}
   141  			s.oldDragPos = normalizedDragOffset
   142  		}
   143  	}
   144  
   145  	// Process events from the indicator so that hover is
   146  	// detected properly.
   147  	for {
   148  		if _, ok := s.indicator.Update(gtx.Source); !ok {
   149  			break
   150  		}
   151  	}
   152  }
   153  
   154  // AddTrack configures the track click listener for the scrollbar to use
   155  // the current clip area.
   156  func (s *Scrollbar) AddTrack(ops *op.Ops) {
   157  	s.track.Add(ops)
   158  }
   159  
   160  // AddIndicator configures the indicator click listener for the scrollbar to use
   161  // the current clip area.
   162  func (s *Scrollbar) AddIndicator(ops *op.Ops) {
   163  	s.indicator.Add(ops)
   164  }
   165  
   166  // AddDrag configures the drag listener for the scrollbar to use
   167  // the current clip area.
   168  func (s *Scrollbar) AddDrag(ops *op.Ops) {
   169  	s.drag.Add(ops)
   170  }
   171  
   172  // IndicatorHovered reports whether the scroll indicator is currently being
   173  // hovered by the pointer.
   174  func (s *Scrollbar) IndicatorHovered() bool {
   175  	return s.indicator.Hovered()
   176  }
   177  
   178  // TrackHovered reports whether the scroll track is being hovered by the
   179  // pointer.
   180  func (s *Scrollbar) TrackHovered() bool {
   181  	return s.track.Hovered()
   182  }
   183  
   184  // ScrollDistance returns the normalized distance that the scrollbar
   185  // moved during the last call to Layout as a value in the range [-1,1].
   186  func (s *Scrollbar) ScrollDistance() float32 {
   187  	return s.delta
   188  }
   189  
   190  // Dragging reports whether the user is currently performing a drag gesture
   191  // on the indicator. Note that this can return false while ScrollDistance is nonzero
   192  // if the user scrolls using a different control than the scrollbar (like a mouse
   193  // wheel).
   194  func (s *Scrollbar) Dragging() bool {
   195  	return s.dragging
   196  }
   197  
   198  // List holds the persistent state for a layout.List that has a
   199  // scrollbar attached.
   200  type List struct {
   201  	Scrollbar
   202  	layout.List
   203  }