list.go 6.5 KB

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