Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[For 2.6] Add drag and drop API to list #4967

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 192 additions & 11 deletions widget/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@
// Since: 2.5
HideSeparators bool

// Enable drag-and-drop of rows within the list
//
// Since: 2.6
EnableDragging bool

// OnDragEnd is the callback that is invoked when a row is dragged and dropped.
// The `draggedTo` value is the ListItemID where the dragged row
// would be inserted, with a value of 0 meaning before the first item
// and a value of list.Length() meaning after the last item in the list.
//
// Since: 2.6
OnDragEnd func(draggedFrom, draggedTo ListItemID) `json:"-"`

// OnDragBegin is the callback invoked when a row begins dragging.
//
// Since: 2.6
OnDragBegin func(id ListItemID) `json:"-"`

currentFocus ListItemID
focused bool
scroller *widget.Scroll
Expand Down Expand Up @@ -89,10 +107,11 @@
l.itemMin = f().MinSize()
}

layout := &fyne.Container{Layout: newListLayout(l)}
ll := newListLayout(l)
layout := &fyne.Container{Layout: ll}
l.scroller = widget.NewVScroll(layout)
layout.Resize(layout.MinSize())
objects := []fyne.CanvasObject{l.scroller}
objects := []fyne.CanvasObject{l.scroller, &ll.(*listLayout).dragSeparator}
return newListRenderer(objects, l, l.scroller, layout)
}

Expand Down Expand Up @@ -383,6 +402,37 @@
return fyne.NewSize(l.itemMin.Width, height+separatorThickness*float32(items-1))
}

func (l *listLayout) calculateDragSeparatorY(thickness float32) float32 {
if l.list.scroller.Size().Height <= 0 {
return 0
}

relY := l.dragRelativeY
if relY < 0 {
relY = 0
} else if h := l.list.Size().Height; relY > h {
relY = h
}

numItems := 0.0
if l.list.Length != nil {
numItems = float64(l.list.Length())
}
if len(l.list.itemHeights) == 0 {
padding := theme.Padding()
paddedItemHeight := l.list.itemMin.Height + padding
beforeItem := math.Round(float64(relY+l.list.offsetY) / float64(paddedItemHeight))
if beforeItem > numItems {
beforeItem = numItems
}
y := float32(beforeItem)*paddedItemHeight - padding/2 - thickness
l.dragInsertAt = ListItemID(beforeItem)
return y
}
// TODO: support item heights
return 0
}

// fills l.visibleRowHeights and also returns offY and minRow
func (l *listLayout) calculateVisibleRowHeights(itemHeight float32, length int) (offY float32, minRow int) {
rowOffset := float32(0)
Expand Down Expand Up @@ -446,6 +496,81 @@
return
}

const (
// max speed (in units per frame) that the list will scroll when dragging above or below
maxScrollSpeed = 500
minScrollSpeed = 3
// how far to drag above or below the top/bottom of the list to reach the max scroll speed
scrollAccelerateRange = 250
)

func (l *listLayout) onRowDragged(id ListItemID, e *fyne.DragEvent) {
if !l.list.EnableDragging {
return
}
startedDrag := false
if l.draggingRow < 0 /*no drag in progress*/ {
l.draggingRow = id
startedDrag = true
}

listPos := fyne.CurrentApp().Driver().AbsolutePositionForObject(l.list.scroller)
// TODO: this may break if the list itself is positioned outside the window viewport?
// don't worry about it now
l.dragRelativeY = e.AbsolutePosition.Y - listPos.Y

animationSpeedCurve := func(x float32) float32 {
// scale to domain: x_: [0, 1]
x_ := math.Min(math.Abs(float64(x)), scrollAccelerateRange) / scrollAccelerateRange
// quadratic, modified by minScrollSpeed
return float32(math.Max(x_*x_*maxScrollSpeed, minScrollSpeed))
}

// distance from top or bottom of list that starts to trigger scrolling animation
scrollStartThreshold := l.list.itemMin.Height / 2

if topThresh := l.dragRelativeY - scrollStartThreshold; topThresh < 0 {
l.scrollAnimSpeed = -animationSpeedCurve(topThresh)
l.ensureStartDragAnim()
} else if bottmThresh := l.list.Size().Height - scrollStartThreshold; l.dragRelativeY > bottmThresh {
l.scrollAnimSpeed = animationSpeedCurve(l.dragRelativeY - bottmThresh)
l.ensureStartDragAnim()
} else {
l.ensureStopDragAnim()
}

l.updateDragSeparator()
if startedDrag && l.list.OnDragBegin != nil {
l.list.OnDragBegin(l.draggingRow)
}
}

func (l *listLayout) onDragEnd() {
startRow := l.draggingRow
l.ensureStopDragAnim()
l.draggingRow = -1
l.dragSeparator.Hide()
if l.list.OnDragEnd != nil {
l.list.OnDragEnd(startRow, l.dragInsertAt)
}
}

func (l *listLayout) ensureStartDragAnim() {
if l.dragScrollAnim == nil {
l.dragScrollAnim = fyne.NewAnimation(math.MaxInt64 /*until stopped*/, func(_ float32) {
l.list.scroller.Scrolled(&fyne.ScrollEvent{Scrolled: fyne.Delta{DY: -l.scrollAnimSpeed}})
})
l.dragScrollAnim.Start()
}
}

func (l *listLayout) ensureStopDragAnim() {
if l.dragScrollAnim != nil {
l.dragScrollAnim.Stop()
l.dragScrollAnim = nil
}
}

// Declare conformity with WidgetRenderer interface.
var _ fyne.WidgetRenderer = (*listRenderer)(nil)

Expand Down Expand Up @@ -477,28 +602,35 @@
}
l.Layout(l.list.Size())
l.scroller.Refresh()
l.layout.Layout.(*listLayout).updateList(false)
layout := l.layout.Layout.(*listLayout)
layout.dragSeparator.FillColor = theme.ForegroundColor()

Check failure on line 606 in widget/list.go

View workflow job for this annotation

GitHub Actions / static_analysis

theme.ForegroundColor is deprecated: Use Color(theme.ColorNameForeground) instead. (SA1019)
layout.dragSeparator.Refresh()
layout.updateList(false)
canvas.Refresh(l.list.super())
}

// Declare conformity with interfaces.
var _ fyne.Widget = (*listItem)(nil)
var _ fyne.Tappable = (*listItem)(nil)
var _ desktop.Hoverable = (*listItem)(nil)
var _ fyne.Draggable = (*listItem)(nil)

type listItem struct {
BaseWidget

id ListItemID
onTapped func()
background *canvas.Rectangle
listLayout *listLayout
child fyne.CanvasObject
hovered, selected bool
}

func newListItem(child fyne.CanvasObject, tapped func()) *listItem {
func newListItem(child fyne.CanvasObject, listLayout *listLayout, tapped func()) *listItem {
li := &listItem{
child: child,
onTapped: tapped,
listLayout: listLayout,
child: child,
onTapped: tapped,
}

li.ExtendBaseWidget(li)
Expand Down Expand Up @@ -526,6 +658,9 @@

// MouseIn is called when a desktop pointer enters the widget.
func (li *listItem) MouseIn(*desktop.MouseEvent) {
if li.listLayout.draggingRow >= 0 {
return
}
li.hovered = true
li.Refresh()
}
Expand All @@ -549,6 +684,14 @@
}
}

func (li *listItem) Dragged(e *fyne.DragEvent) {
li.listLayout.onRowDragged(li.id, e)
}

func (li *listItem) DragEnd() {
li.listLayout.onDragEnd()
}

// Declare conformity with the WidgetRenderer interface.
var _ fyne.WidgetRenderer = (*listItemRenderer)(nil)

Expand Down Expand Up @@ -593,24 +736,36 @@
id ListItemID
}

// thickness: theme.SeparatorThicknessSize() * dragSeparatorThicknessMultiplier
const dragSeparatorThicknessMultiplier = 1.5

type listLayout struct {
list *List
separators []fyne.CanvasObject
children []fyne.CanvasObject
list *List
separators []fyne.CanvasObject
children []fyne.CanvasObject
dragSeparator canvas.Rectangle

itemPool syncPool
visible []listItemAndID
slicePool sync.Pool // *[]itemAndID
visibleRowHeights []float32
renderLock sync.RWMutex

draggingRow ListItemID // -1 if no drag
dragRelativeY float32 // 0 == top of list widget
dragInsertAt ListItemID
dragScrollAnim *fyne.Animation
scrollAnimSpeed float32
}

func newListLayout(list *List) fyne.Layout {
l := &listLayout{list: list}
l := &listLayout{list: list, draggingRow: -1}
l.slicePool.New = func() any {
s := make([]listItemAndID, 0)
return &s
}
l.dragSeparator.FillColor = theme.ForegroundColor()

Check failure on line 767 in widget/list.go

View workflow job for this annotation

GitHub Actions / static_analysis

theme.ForegroundColor is deprecated: Use Color(theme.ColorNameForeground) instead. (SA1019)
l.dragSeparator.Hidden = true
list.offsetUpdated = l.offsetUpdated
return l
}
Expand All @@ -627,7 +782,7 @@
item := l.itemPool.Obtain()
if item == nil {
if f := l.list.CreateItem; f != nil {
item = newListItem(f(), nil)
item = newListItem(f(), l, nil)
}
}
return item.(*listItem)
Expand All @@ -637,11 +792,18 @@
if l.list.offsetY == pos.Y {
return
}
l.renderLock.Lock()
l.list.offsetY = pos.Y
if l.draggingRow >= 0 {
l.updateDragSeparator()
}
l.renderLock.Unlock()
// updateList grabs the renderLock
l.updateList(true)
}

func (l *listLayout) setupListItem(li *listItem, id ListItemID, focus bool) {
li.id = id
previousIndicator := li.selected
li.selected = false
for _, s := range l.list.selected {
Expand Down Expand Up @@ -776,7 +938,26 @@
l.slicePool.Put(visiblePtr)
}

func (l *listLayout) updateDragSeparator() {
listSize := l.list.Size()
thickness := theme.SeparatorThicknessSize() * dragSeparatorThicknessMultiplier
l.dragSeparator.Resize(fyne.NewSize(listSize.Width, thickness))
sepY := l.calculateDragSeparatorY(thickness) - l.list.offsetY
padding := theme.Padding()
if sepY > listSize.Height+padding || sepY < -padding {
// use margin of [-padding, padding] make sure
// it can be shown above/below first and last items
l.dragSeparator.Hide()
return
}
l.dragSeparator.Move(fyne.NewPos(0, sepY))
l.dragSeparator.Show()
}

func (l *listLayout) updateSeparators() {
if l.draggingRow >= 0 {
l.updateDragSeparator()
}
if l.list.HideSeparators {
l.separators = nil
return
Expand Down
2 changes: 1 addition & 1 deletion widget/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestNewList(t *testing.T) {
NewIcon(theme.DocumentIcon()),
NewLabel("Template Object")},
}
template := newListItem(content, nil)
template := newListItem(content, nil, nil)

assert.Equal(t, 1000, list.Length())
assert.GreaterOrEqual(t, list.MinSize().Width, template.MinSize().Width)
Expand Down
Loading