list.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. // SPDX-License-Identifier: Unlicense OR MIT
  2. package material
  3. import (
  4. "image"
  5. "image/color"
  6. "math"
  7. "gioui.org/io/pointer"
  8. "gioui.org/layout"
  9. "gioui.org/op"
  10. "gioui.org/op/clip"
  11. "gioui.org/op/paint"
  12. "gioui.org/unit"
  13. "gioui.org/widget"
  14. )
  15. // fromListPosition converts a layout.Position into two floats representing
  16. // the location of the viewport on the underlying content. It needs to know
  17. // the number of elements in the list and the major-axis size of the list
  18. // in order to do this. The returned values will be in the range [0,1], and
  19. // start will be less than or equal to end.
  20. func fromListPosition(lp layout.Position, elements int, majorAxisSize int) (start, end float32) {
  21. // Approximate the size of the scrollable content.
  22. lengthEstPx := float32(lp.Length)
  23. elementLenEstPx := lengthEstPx / float32(elements)
  24. // Determine how much of the content is visible.
  25. listOffsetF := float32(lp.Offset)
  26. listOffsetL := float32(lp.OffsetLast)
  27. // Compute the location of the beginning of the viewport using estimated element size and known
  28. // pixel offsets.
  29. viewportStart := clamp1((float32(lp.First)*elementLenEstPx + listOffsetF) / lengthEstPx)
  30. viewportEnd := clamp1((float32(lp.First+lp.Count)*elementLenEstPx + listOffsetL) / lengthEstPx)
  31. viewportFraction := viewportEnd - viewportStart
  32. // Compute the expected visible proportion of the list content based solely on the ratio
  33. // of the visible size and the estimated total size.
  34. visiblePx := float32(majorAxisSize)
  35. visibleFraction := visiblePx / lengthEstPx
  36. // Compute the error between the two methods of determining the viewport and diffuse the
  37. // error on either end of the viewport based on how close we are to each end.
  38. err := visibleFraction - viewportFraction
  39. adjStart := viewportStart
  40. adjEnd := viewportEnd
  41. if viewportFraction < 1 {
  42. startShare := viewportStart / (1 - viewportFraction)
  43. endShare := (1 - viewportEnd) / (1 - viewportFraction)
  44. startErr := startShare * err
  45. endErr := endShare * err
  46. adjStart -= startErr
  47. adjEnd += endErr
  48. }
  49. return adjStart, adjEnd
  50. }
  51. // rangeIsScrollable returns whether the viewport described by start and end
  52. // is smaller than the underlying content (such that it can be scrolled).
  53. // start and end are expected to each be in the range [0,1], and start
  54. // must be less than or equal to end.
  55. func rangeIsScrollable(start, end float32) bool {
  56. return end-start < 1
  57. }
  58. // ScrollTrackStyle configures the presentation of a track for a scroll area.
  59. type ScrollTrackStyle struct {
  60. // MajorPadding and MinorPadding along the major and minor axis of the
  61. // scrollbar's track. This is used to keep the scrollbar from touching
  62. // the edges of the content area.
  63. MajorPadding, MinorPadding unit.Dp
  64. // Color of the track background.
  65. Color color.NRGBA
  66. }
  67. // ScrollIndicatorStyle configures the presentation of a scroll indicator.
  68. type ScrollIndicatorStyle struct {
  69. // MajorMinLen is the smallest that the scroll indicator is allowed to
  70. // be along the major axis.
  71. MajorMinLen unit.Dp
  72. // MinorWidth is the width of the scroll indicator across the minor axis.
  73. MinorWidth unit.Dp
  74. // Color and HoverColor are the normal and hovered colors of the scroll
  75. // indicator.
  76. Color, HoverColor color.NRGBA
  77. // CornerRadius is the corner radius of the rectangular indicator. 0
  78. // will produce square corners. 0.5*MinorWidth will produce perfectly
  79. // round corners.
  80. CornerRadius unit.Dp
  81. }
  82. // ScrollbarStyle configures the presentation of a scrollbar.
  83. type ScrollbarStyle struct {
  84. Scrollbar *widget.Scrollbar
  85. Track ScrollTrackStyle
  86. Indicator ScrollIndicatorStyle
  87. }
  88. // Scrollbar configures the presentation of a scrollbar using the provided
  89. // theme and state.
  90. func Scrollbar(th *Theme, state *widget.Scrollbar) ScrollbarStyle {
  91. lightFg := th.Palette.Fg
  92. lightFg.A = 150
  93. darkFg := lightFg
  94. darkFg.A = 200
  95. return ScrollbarStyle{
  96. Scrollbar: state,
  97. Track: ScrollTrackStyle{
  98. MajorPadding: 2,
  99. MinorPadding: 2,
  100. },
  101. Indicator: ScrollIndicatorStyle{
  102. MajorMinLen: th.FingerSize,
  103. MinorWidth: 6,
  104. CornerRadius: 3,
  105. Color: lightFg,
  106. HoverColor: darkFg,
  107. },
  108. }
  109. }
  110. // Width returns the minor axis width of the scrollbar in its current
  111. // configuration (taking padding for the scroll track into account).
  112. func (s ScrollbarStyle) Width() unit.Dp {
  113. return s.Indicator.MinorWidth + s.Track.MinorPadding + s.Track.MinorPadding
  114. }
  115. // Layout the scrollbar.
  116. func (s ScrollbarStyle) Layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
  117. if !rangeIsScrollable(viewportStart, viewportEnd) {
  118. return layout.Dimensions{}
  119. }
  120. // Set minimum constraints in an axis-independent way, then convert to
  121. // the correct representation for the current axis.
  122. convert := axis.Convert
  123. maxMajorAxis := convert(gtx.Constraints.Max).X
  124. gtx.Constraints.Min.X = maxMajorAxis
  125. gtx.Constraints.Min.Y = gtx.Dp(s.Width())
  126. gtx.Constraints.Min = convert(gtx.Constraints.Min)
  127. gtx.Constraints.Max = gtx.Constraints.Min
  128. s.Scrollbar.Update(gtx, axis, viewportStart, viewportEnd)
  129. // Darken indicator if hovered.
  130. if s.Scrollbar.IndicatorHovered() {
  131. s.Indicator.Color = s.Indicator.HoverColor
  132. }
  133. return s.layout(gtx, axis, viewportStart, viewportEnd)
  134. }
  135. // layout the scroll track and indicator.
  136. func (s ScrollbarStyle) layout(gtx layout.Context, axis layout.Axis, viewportStart, viewportEnd float32) layout.Dimensions {
  137. inset := layout.Inset{
  138. Top: s.Track.MajorPadding,
  139. Bottom: s.Track.MajorPadding,
  140. Left: s.Track.MinorPadding,
  141. Right: s.Track.MinorPadding,
  142. }
  143. if axis == layout.Horizontal {
  144. inset.Top, inset.Bottom, inset.Left, inset.Right = inset.Left, inset.Right, inset.Top, inset.Bottom
  145. }
  146. return layout.Background{}.Layout(gtx,
  147. func(gtx layout.Context) layout.Dimensions {
  148. // Lay out the draggable track underneath the scroll indicator.
  149. area := image.Rectangle{
  150. Max: gtx.Constraints.Min,
  151. }
  152. pointerArea := clip.Rect(area)
  153. defer pointerArea.Push(gtx.Ops).Pop()
  154. s.Scrollbar.AddDrag(gtx.Ops)
  155. // Stack a normal clickable area on top of the draggable area
  156. // to capture non-dragging clicks.
  157. defer pointer.PassOp{}.Push(gtx.Ops).Pop()
  158. defer pointerArea.Push(gtx.Ops).Pop()
  159. s.Scrollbar.AddTrack(gtx.Ops)
  160. paint.FillShape(gtx.Ops, s.Track.Color, clip.Rect(area).Op())
  161. return layout.Dimensions{Size: gtx.Constraints.Min}
  162. },
  163. func(gtx layout.Context) layout.Dimensions {
  164. return inset.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
  165. // Use axis-independent constraints.
  166. gtx.Constraints.Min = axis.Convert(gtx.Constraints.Min)
  167. gtx.Constraints.Max = axis.Convert(gtx.Constraints.Max)
  168. // Compute the pixel size and position of the scroll indicator within
  169. // the track.
  170. trackLen := gtx.Constraints.Min.X
  171. viewStart := int(math.Round(float64(viewportStart) * float64(trackLen)))
  172. viewEnd := int(math.Round(float64(viewportEnd) * float64(trackLen)))
  173. indicatorLen := max(viewEnd-viewStart, gtx.Dp(s.Indicator.MajorMinLen))
  174. if viewStart+indicatorLen > trackLen {
  175. viewStart = trackLen - indicatorLen
  176. }
  177. indicatorDims := axis.Convert(image.Point{
  178. X: indicatorLen,
  179. Y: gtx.Dp(s.Indicator.MinorWidth),
  180. })
  181. radius := gtx.Dp(s.Indicator.CornerRadius)
  182. // Lay out the indicator.
  183. offset := axis.Convert(image.Pt(viewStart, 0))
  184. defer op.Offset(offset).Push(gtx.Ops).Pop()
  185. paint.FillShape(gtx.Ops, s.Indicator.Color, clip.RRect{
  186. Rect: image.Rectangle{
  187. Max: indicatorDims,
  188. },
  189. SW: radius,
  190. NW: radius,
  191. NE: radius,
  192. SE: radius,
  193. }.Op(gtx.Ops))
  194. // Add the indicator pointer hit area.
  195. area := clip.Rect(image.Rectangle{Max: indicatorDims})
  196. defer pointer.PassOp{}.Push(gtx.Ops).Pop()
  197. defer area.Push(gtx.Ops).Pop()
  198. s.Scrollbar.AddIndicator(gtx.Ops)
  199. return layout.Dimensions{Size: axis.Convert(gtx.Constraints.Min)}
  200. })
  201. },
  202. )
  203. }
  204. // AnchorStrategy defines a means of attaching a scrollbar to content.
  205. type AnchorStrategy uint8
  206. const (
  207. // Occupy reserves space for the scrollbar, making the underlying
  208. // content region smaller on one axis.
  209. Occupy AnchorStrategy = iota
  210. // Overlay causes the scrollbar to float atop the content without
  211. // occupying any space. Content in the underlying area can be occluded
  212. // by the scrollbar.
  213. Overlay
  214. )
  215. // ListStyle configures the presentation of a layout.List with a scrollbar.
  216. type ListStyle struct {
  217. state *widget.List
  218. ScrollbarStyle
  219. AnchorStrategy
  220. }
  221. // List constructs a ListStyle using the provided theme and state.
  222. func List(th *Theme, state *widget.List) ListStyle {
  223. return ListStyle{
  224. state: state,
  225. ScrollbarStyle: Scrollbar(th, &state.Scrollbar),
  226. }
  227. }
  228. // Layout the list and its scrollbar.
  229. func (l ListStyle) Layout(gtx layout.Context, length int, w layout.ListElement) layout.Dimensions {
  230. originalConstraints := gtx.Constraints
  231. // Determine how much space the scrollbar occupies.
  232. barWidth := gtx.Dp(l.Width())
  233. if l.AnchorStrategy == Occupy {
  234. // Reserve space for the scrollbar using the gtx constraints.
  235. max := l.state.Axis.Convert(gtx.Constraints.Max)
  236. min := l.state.Axis.Convert(gtx.Constraints.Min)
  237. max.Y -= barWidth
  238. if max.Y < 0 {
  239. max.Y = 0
  240. }
  241. min.Y -= barWidth
  242. if min.Y < 0 {
  243. min.Y = 0
  244. }
  245. gtx.Constraints.Max = l.state.Axis.Convert(max)
  246. gtx.Constraints.Min = l.state.Axis.Convert(min)
  247. }
  248. listDims := l.state.List.Layout(gtx, length, w)
  249. gtx.Constraints = originalConstraints
  250. // Draw the scrollbar.
  251. anchoring := layout.E
  252. if l.state.Axis == layout.Horizontal {
  253. anchoring = layout.S
  254. }
  255. majorAxisSize := l.state.Axis.Convert(listDims.Size).X
  256. start, end := fromListPosition(l.state.Position, length, majorAxisSize)
  257. // layout.Direction respects the minimum, so ensure that the
  258. // scrollbar will be drawn on the correct edge even if the provided
  259. // layout.Context had a zero minimum constraint.
  260. gtx.Constraints.Min = listDims.Size
  261. if l.AnchorStrategy == Occupy {
  262. min := l.state.Axis.Convert(gtx.Constraints.Min)
  263. min.Y += barWidth
  264. gtx.Constraints.Min = l.state.Axis.Convert(min)
  265. }
  266. anchoring.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
  267. return l.ScrollbarStyle.Layout(gtx, l.state.Axis, start, end)
  268. })
  269. if delta := l.state.ScrollDistance(); delta != 0 {
  270. // Handle any changes to the list position as a result of user interaction
  271. // with the scrollbar.
  272. l.state.List.ScrollBy(delta * float32(length))
  273. }
  274. if l.AnchorStrategy == Occupy {
  275. // Increase the width to account for the space occupied by the scrollbar.
  276. cross := l.state.Axis.Convert(listDims.Size)
  277. cross.Y += barWidth
  278. listDims.Size = l.state.Axis.Convert(cross)
  279. }
  280. return listDims
  281. }