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  }