diff --git a/widget/gridwrap.go b/widget/gridwrap.go index 372f529045..91db66a6b3 100644 --- a/widget/gridwrap.go +++ b/widget/gridwrap.go @@ -25,6 +25,21 @@ var ( // Since: 2.4 type GridWrapItemID = int +// GridWrapStretch defines how GridWrap items should stretch to fill the available width. +// +// Since: 2.8 +type GridWrapStretch int + +const ( + // GridWrapStretchNone indicates that items should not be stretched. + GridWrapStretchNone GridWrapStretch = iota + // GridWrapStretchHorizontal indicates that items should be stretched horizontally. + GridWrapStretchHorizontal + // GridWrapStretchBoth indicates that items should be stretched horizontally and vertically + // to maintain the aspect ratio. + GridWrapStretchBoth +) + // GridWrap is a widget with an API very similar to widget.List, // that lays out items in a scrollable wrapping grid similar to container.NewGridWrap. // It caches and reuses widgets for performance. @@ -60,6 +75,13 @@ type GridWrap struct { // Since: 2.8 OnHighlighted func(id GridWrapItemID) `json:"-"` + // StretchItems, when set, stretches items to fill the + // available width evenly across columns. This avoids gaps on the right + // side when the viewport width doesn't divide evenly by item width. + // + // Since: 2.8 + StretchItems GridWrapStretch + currentHighlight ListItemID focused bool scroller *widget.Scroll @@ -641,8 +663,27 @@ func (l *gridWrapLayout) updateGrid(newOnly bool) { colCount := l.gw.ColumnCount() visibleRowsCount := int(math.Ceil(float64(l.gw.scroller.Size().Height)/float64(l.gw.itemMin.Height+padding))) + 1 - offY := l.gw.offsetY - float32(math.Mod(float64(l.gw.offsetY), float64(l.gw.itemMin.Height+padding))) - minRow := int(offY / (l.gw.itemMin.Height + padding)) + // Calculate item size. When StretchItems is enabled, stretch items horizontally + // to fill the available width evenly across columns. This is calculated here + // at layout time to avoid feedback loops from having MinSize depend on viewport. + itemSize := l.gw.itemMin + if l.gw.StretchItems != GridWrapStretchNone && colCount > 0 { + viewportWidth := l.gw.scroller.Size().Width + stretchedWidth := (viewportWidth - float32(colCount-1)*padding) / float32(colCount) + if stretchedWidth > l.gw.itemMin.Width { + if l.gw.StretchItems == GridWrapStretchBoth { + aspect := l.gw.itemMin.Width / l.gw.itemMin.Height + itemSize = fyne.NewSize(stretchedWidth, stretchedWidth/aspect) + } else { + itemSize = fyne.NewSize(stretchedWidth, l.gw.itemMin.Height) + } + } + } + stepX := itemSize.Width + padding + stepY := l.gw.itemMin.Height + padding // Use base height for row calculation + + offY := l.gw.offsetY - float32(math.Mod(float64(l.gw.offsetY), float64(stepY))) + minRow := int(offY / stepY) minItem := minRow * colCount maxRow := int(math.Min(float64(minRow+visibleRowsCount), math.Ceil(float64(length)/float64(colCount)))) maxItem := GridWrapItemID(math.Min(float64(maxRow*colCount), float64(length-1))) @@ -670,18 +711,18 @@ func (l *gridWrapLayout) updateGrid(newOnly bool) { if item == nil { continue } - item.Resize(l.gw.itemMin) + item.Resize(itemSize) } item.Move(fyne.NewPos(x, y)) - item.Resize(l.gw.itemMin) + item.Resize(itemSize) - x += l.gw.itemMin.Width + padding + x += stepX l.visible = append(l.visible, gridItemAndID{item: item, id: curItemID}) c.Objects = append(c.Objects, item) curItemID++ } - y += l.gw.itemMin.Height + padding + y += stepY } l.nilOldSliceData(c.Objects, len(c.Objects), oldObjLen) diff --git a/widget/gridwrap_test.go b/widget/gridwrap_test.go index b2e70f49f7..2fc35571d6 100644 --- a/widget/gridwrap_test.go +++ b/widget/gridwrap_test.go @@ -1,9 +1,11 @@ package widget import ( + "image/color" "testing" "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" "fyne.io/fyne/v2/test" "fyne.io/fyne/v2/theme" "github.com/stretchr/testify/assert" @@ -293,3 +295,97 @@ func TestGridWrap_TypedKey(t *testing.T) { gridWrap.TypedKey(&fyne.KeyEvent{Name: fyne.KeyRight}) assert.Equal(t, 20, gridWrap.currentHighlight) } + +func TestGridWrap_StretchItems_DisabledKeepsMinWidth(t *testing.T) { + test.NewTempApp(t) + + g := createGridWrapWithMin(4, fyne.NewSize(20, 10)) + w := test.NewTempWindow(t, g) + w.Resize(fyne.NewSize(103, 40)) + + children := g.scroller.Content.(*fyne.Container).Objects + assert.Len(t, children, 4) + assert.Equal(t, float32(20), children[0].Size().Width) + assert.Equal(t, float32(20), children[3].Size().Width) +} + +func TestGridWrap_StretchItems_EnabledStretchesToFillRow(t *testing.T) { + test.NewTempApp(t) + + g := createGridWrapWithMin(4, fyne.NewSize(20, 10)) + g.StretchItems = GridWrapStretchHorizontal + w := test.NewTempWindow(t, g) + w.Resize(fyne.NewSize(103, 40)) + + padding := g.Theme().Size(theme.SizeNamePadding) + colCount := g.ColumnCount() + assert.Equal(t, 4, colCount) + + viewportWidth := g.scroller.Size().Width + expectedWidth := (viewportWidth - float32(colCount-1)*padding) / float32(colCount) + assert.Greater(t, expectedWidth, g.itemMin.Width) + + children := g.scroller.Content.(*fyne.Container).Objects + assert.Len(t, children, 4) + assert.InDelta(t, float64(expectedWidth), float64(children[0].Size().Width), 0.001) + assert.InDelta(t, float64(expectedWidth), float64(children[3].Size().Width), 0.001) + + lastRight := children[colCount-1].Position().X + children[colCount-1].Size().Width + assert.InDelta(t, float64(viewportWidth), float64(lastRight), 0.001) +} + +func TestGridWrap_StretchItems_DoesNotShrinkItems(t *testing.T) { + test.NewTempApp(t) + + g := createGridWrapWithMin(1, fyne.NewSize(20, 10)) + g.StretchItems = GridWrapStretchHorizontal + w := test.NewTempWindow(t, g) + w.Resize(fyne.NewSize(15, 40)) + + children := g.scroller.Content.(*fyne.Container).Objects + assert.Len(t, children, 1) + assert.Equal(t, float32(20), children[0].Size().Width) +} + +func TestGridWrap_StretchItems_Both(t *testing.T) { + test.NewTempApp(t) + + g := createGridWrapWithMin(4, fyne.NewSize(20, 10)) + g.StretchItems = GridWrapStretchBoth + w := test.NewTempWindow(t, g) + w.Resize(fyne.NewSize(103, 40)) + + padding := g.Theme().Size(theme.SizeNamePadding) + colCount := g.ColumnCount() + // 4 items, min width 20, padding 4. + // 103 width -> (103 + 4) / (20 + 4) = 107 / 24 = 4.45 -> 4 columns. + assert.Equal(t, 4, colCount) + + viewportWidth := g.scroller.Size().Width + // Expected width: (103 - 3*4) / 4 = (103 - 12) / 4 = 91 / 4 = 22.75 + expectedWidth := (viewportWidth - float32(colCount-1)*padding) / float32(colCount) + assert.Greater(t, expectedWidth, g.itemMin.Width) + + // Aspect ratio is 20/10 = 2. + // Expected height: 22.75 / 2 = 11.375 + expectedHeight := expectedWidth / (g.itemMin.Width / g.itemMin.Height) + + children := g.scroller.Content.(*fyne.Container).Objects + assert.Len(t, children, 4) + assert.InDelta(t, float64(expectedWidth), float64(children[0].Size().Width), 0.001) + assert.InDelta(t, float64(expectedHeight), float64(children[0].Size().Height), 0.001) +} + +func createGridWrapWithMin(items int, min fyne.Size) *GridWrap { + list := NewGridWrap( + func() int { return items }, + func() fyne.CanvasObject { + rect := canvas.NewRectangle(color.Transparent) + rect.SetMinSize(min) + return rect + }, + func(id GridWrapItemID, item fyne.CanvasObject) {}, + ) + list.Resize(fyne.NewSize(200, 200)) + return list +}