gioui.org@v0.6.1-0.20240506124620-7a9ce51988ce/layout/list.go (about) 1 // SPDX-License-Identifier: Unlicense OR MIT 2 3 package layout 4 5 import ( 6 "image" 7 "math" 8 9 "gioui.org/gesture" 10 "gioui.org/io/pointer" 11 "gioui.org/op" 12 "gioui.org/op/clip" 13 ) 14 15 type scrollChild struct { 16 size image.Point 17 call op.CallOp 18 } 19 20 // List displays a subsection of a potentially infinitely 21 // large underlying list. List accepts user input to scroll 22 // the subsection. 23 type List struct { 24 Axis Axis 25 // ScrollToEnd instructs the list to stay scrolled to the far end position 26 // once reached. A List with ScrollToEnd == true and Position.BeforeEnd == 27 // false draws its content with the last item at the bottom of the list 28 // area. 29 ScrollToEnd bool 30 // Alignment is the cross axis alignment of list elements. 31 Alignment Alignment 32 33 cs Constraints 34 scroll gesture.Scroll 35 scrollDelta int 36 37 // Position is updated during Layout. To save the list scroll position, 38 // just save Position after Layout finishes. To scroll the list 39 // programmatically, update Position (e.g. restore it from a saved value) 40 // before calling Layout. 41 Position Position 42 43 len int 44 45 // maxSize is the total size of visible children. 46 maxSize int 47 children []scrollChild 48 dir iterationDir 49 } 50 51 // ListElement is a function that computes the dimensions of 52 // a list element. 53 type ListElement func(gtx Context, index int) Dimensions 54 55 type iterationDir uint8 56 57 // Position is a List scroll offset represented as an offset from the top edge 58 // of a child element. 59 type Position struct { 60 // BeforeEnd tracks whether the List position is before the very end. We 61 // use "before end" instead of "at end" so that the zero value of a 62 // Position struct is useful. 63 // 64 // When laying out a list, if ScrollToEnd is true and BeforeEnd is false, 65 // then First and Offset are ignored, and the list is drawn with the last 66 // item at the bottom. If ScrollToEnd is false then BeforeEnd is ignored. 67 BeforeEnd bool 68 // First is the index of the first visible child. 69 First int 70 // Offset is the distance in pixels from the leading edge to the child at index 71 // First. 72 Offset int 73 // OffsetLast is the signed distance in pixels from the trailing edge to the 74 // bottom edge of the child at index First+Count. 75 OffsetLast int 76 // Count is the number of visible children. 77 Count int 78 // Length is the estimated total size of all children, measured in pixels. 79 Length int 80 } 81 82 const ( 83 iterateNone iterationDir = iota 84 iterateForward 85 iterateBackward 86 ) 87 88 const inf = 1e6 89 90 // init prepares the list for iterating through its children with next. 91 func (l *List) init(gtx Context, len int) { 92 if l.more() { 93 panic("unfinished child") 94 } 95 l.cs = gtx.Constraints 96 l.maxSize = 0 97 l.children = l.children[:0] 98 l.len = len 99 l.update(gtx) 100 if l.Position.First < 0 { 101 l.Position.Offset = 0 102 l.Position.First = 0 103 } 104 if l.scrollToEnd() || l.Position.First > len { 105 l.Position.Offset = 0 106 l.Position.First = len 107 } 108 } 109 110 // Layout a List of len items, where each item is implicitly defined 111 // by the callback w. Layout can handle very large lists because it only calls 112 // w to fill its viewport and the distance scrolled, if any. 113 func (l *List) Layout(gtx Context, len int, w ListElement) Dimensions { 114 l.init(gtx, len) 115 crossMin, crossMax := l.Axis.crossConstraint(gtx.Constraints) 116 gtx.Constraints = l.Axis.constraints(0, inf, crossMin, crossMax) 117 macro := op.Record(gtx.Ops) 118 laidOutTotalLength := 0 119 numLaidOut := 0 120 121 for l.next(); l.more(); l.next() { 122 child := op.Record(gtx.Ops) 123 dims := w(gtx, l.index()) 124 call := child.Stop() 125 l.end(dims, call) 126 laidOutTotalLength += l.Axis.Convert(dims.Size).X 127 numLaidOut++ 128 } 129 130 if numLaidOut > 0 { 131 l.Position.Length = laidOutTotalLength * len / numLaidOut 132 } else { 133 l.Position.Length = 0 134 } 135 return l.layout(gtx.Ops, macro) 136 } 137 138 func (l *List) scrollToEnd() bool { 139 return l.ScrollToEnd && !l.Position.BeforeEnd 140 } 141 142 // Dragging reports whether the List is being dragged. 143 func (l *List) Dragging() bool { 144 return l.scroll.State() == gesture.StateDragging 145 } 146 147 func (l *List) update(gtx Context) { 148 min, max := int(-inf), int(inf) 149 if l.Position.First == 0 { 150 // Use the size of the invisible part as scroll boundary. 151 min = -l.Position.Offset 152 if min > 0 { 153 min = 0 154 } 155 } 156 if l.Position.First+l.Position.Count == l.len { 157 max = -l.Position.OffsetLast 158 if max < 0 { 159 max = 0 160 } 161 } 162 xrange := pointer.ScrollRange{Min: min, Max: max} 163 yrange := pointer.ScrollRange{} 164 if l.Axis == Vertical { 165 xrange, yrange = yrange, xrange 166 } 167 d := l.scroll.Update(gtx.Metric, gtx.Source, gtx.Now, gesture.Axis(l.Axis), xrange, yrange) 168 l.scrollDelta = d 169 l.Position.Offset += d 170 } 171 172 // next advances to the next child. 173 func (l *List) next() { 174 l.dir = l.nextDir() 175 // The user scroll offset is applied after scrolling to 176 // list end. 177 if l.scrollToEnd() && !l.more() && l.scrollDelta < 0 { 178 l.Position.BeforeEnd = true 179 l.Position.Offset += l.scrollDelta 180 l.dir = l.nextDir() 181 } 182 } 183 184 // index is current child's position in the underlying list. 185 func (l *List) index() int { 186 switch l.dir { 187 case iterateBackward: 188 return l.Position.First - 1 189 case iterateForward: 190 return l.Position.First + len(l.children) 191 default: 192 panic("Index called before Next") 193 } 194 } 195 196 // more reports whether more children are needed. 197 func (l *List) more() bool { 198 return l.dir != iterateNone 199 } 200 201 func (l *List) nextDir() iterationDir { 202 _, vsize := l.Axis.mainConstraint(l.cs) 203 last := l.Position.First + len(l.children) 204 // Clamp offset. 205 if l.maxSize-l.Position.Offset < vsize && last == l.len { 206 l.Position.Offset = l.maxSize - vsize 207 } 208 if l.Position.Offset < 0 && l.Position.First == 0 { 209 l.Position.Offset = 0 210 } 211 // Lay out an extra (invisible) child at each end to enable focus to 212 // move to them, triggering automatic scroll. 213 firstSize, lastSize := 0, 0 214 if len(l.children) > 0 { 215 if l.Position.First > 0 { 216 firstChild := l.children[0] 217 firstSize = l.Axis.Convert(firstChild.size).X 218 } 219 if last < l.len { 220 lastChild := l.children[len(l.children)-1] 221 lastSize = l.Axis.Convert(lastChild.size).X 222 } 223 } 224 switch { 225 case len(l.children) == l.len: 226 return iterateNone 227 case l.maxSize-l.Position.Offset-lastSize < vsize: 228 return iterateForward 229 case l.Position.Offset-firstSize < 0: 230 return iterateBackward 231 } 232 return iterateNone 233 } 234 235 // End the current child by specifying its dimensions. 236 func (l *List) end(dims Dimensions, call op.CallOp) { 237 child := scrollChild{dims.Size, call} 238 mainSize := l.Axis.Convert(child.size).X 239 l.maxSize += mainSize 240 switch l.dir { 241 case iterateForward: 242 l.children = append(l.children, child) 243 case iterateBackward: 244 l.children = append(l.children, scrollChild{}) 245 copy(l.children[1:], l.children) 246 l.children[0] = child 247 l.Position.First-- 248 l.Position.Offset += mainSize 249 default: 250 panic("call Next before End") 251 } 252 l.dir = iterateNone 253 } 254 255 // Layout the List and return its dimensions. 256 func (l *List) layout(ops *op.Ops, macro op.MacroOp) Dimensions { 257 if l.more() { 258 panic("unfinished child") 259 } 260 mainMin, mainMax := l.Axis.mainConstraint(l.cs) 261 children := l.children 262 var first scrollChild 263 // Skip invisible children. 264 for len(children) > 0 { 265 child := children[0] 266 sz := child.size 267 mainSize := l.Axis.Convert(sz).X 268 if l.Position.Offset < mainSize { 269 // First child is partially visible. 270 break 271 } 272 l.Position.First++ 273 l.Position.Offset -= mainSize 274 first = child 275 children = children[1:] 276 } 277 size := -l.Position.Offset 278 var maxCross int 279 var last scrollChild 280 for i, child := range children { 281 sz := l.Axis.Convert(child.size) 282 if c := sz.Y; c > maxCross { 283 maxCross = c 284 } 285 size += sz.X 286 if size >= mainMax { 287 if i < len(children)-1 { 288 last = children[i+1] 289 } 290 children = children[:i+1] 291 break 292 } 293 } 294 l.Position.Count = len(children) 295 l.Position.OffsetLast = mainMax - size 296 // ScrollToEnd lists are end aligned. 297 if space := l.Position.OffsetLast; l.ScrollToEnd && space > 0 { 298 l.Position.Offset -= space 299 } 300 pos := -l.Position.Offset 301 layout := func(child scrollChild) { 302 sz := l.Axis.Convert(child.size) 303 var cross int 304 switch l.Alignment { 305 case End: 306 cross = maxCross - sz.Y 307 case Middle: 308 cross = (maxCross - sz.Y) / 2 309 } 310 childSize := sz.X 311 min := pos 312 if min < 0 { 313 min = 0 314 } 315 pt := l.Axis.Convert(image.Pt(pos, cross)) 316 trans := op.Offset(pt).Push(ops) 317 child.call.Add(ops) 318 trans.Pop() 319 pos += childSize 320 } 321 // Lay out leading invisible child. 322 if first != (scrollChild{}) { 323 sz := l.Axis.Convert(first.size) 324 pos -= sz.X 325 layout(first) 326 } 327 for _, child := range children { 328 layout(child) 329 } 330 // Lay out trailing invisible child. 331 if last != (scrollChild{}) { 332 layout(last) 333 } 334 atStart := l.Position.First == 0 && l.Position.Offset <= 0 335 atEnd := l.Position.First+len(children) == l.len && mainMax >= pos 336 if atStart && l.scrollDelta < 0 || atEnd && l.scrollDelta > 0 { 337 l.scroll.Stop() 338 } 339 l.Position.BeforeEnd = !atEnd 340 if pos < mainMin { 341 pos = mainMin 342 } 343 if pos > mainMax { 344 pos = mainMax 345 } 346 if crossMin, crossMax := l.Axis.crossConstraint(l.cs); maxCross < crossMin { 347 maxCross = crossMin 348 } else if maxCross > crossMax { 349 maxCross = crossMax 350 } 351 dims := l.Axis.Convert(image.Pt(pos, maxCross)) 352 call := macro.Stop() 353 defer clip.Rect(image.Rectangle{Max: dims}).Push(ops).Pop() 354 355 l.scroll.Add(ops) 356 357 call.Add(ops) 358 return Dimensions{Size: dims} 359 } 360 361 // ScrollBy scrolls the list by a relative amount of items. 362 // 363 // Fractional scrolling may be inaccurate for items of differing 364 // dimensions. This includes scrolling by integer amounts if the current 365 // l.Position.Offset is non-zero. 366 func (l *List) ScrollBy(num float32) { 367 // Split number of items into integer and fractional parts 368 i, f := math.Modf(float64(num)) 369 370 // Scroll by integer amount of items 371 l.Position.First += int(i) 372 373 // Adjust Offset to account for fractional items. If Offset gets so large that it amounts to an entire item, then 374 // the layout code will handle that for us and adjust First and Offset accordingly. 375 itemHeight := float64(l.Position.Length) / float64(l.len) 376 l.Position.Offset += int(math.Round(itemHeight * f)) 377 378 // First and Offset can go out of bounds, but the layout code knows how to handle that. 379 380 // Ensure that the list pays attention to the Offset field when the scrollbar drag 381 // is started while the bar is at the end of the list. Without this, the scrollbar 382 // cannot be dragged away from the end. 383 l.Position.BeforeEnd = true 384 } 385 386 // ScrollTo scrolls to the specified item. 387 func (l *List) ScrollTo(n int) { 388 l.Position.First = n 389 l.Position.Offset = 0 390 l.Position.BeforeEnd = true 391 }