Skip to content

Commit 8860ee9

Browse files
authored
widget: rebuild form item render slot when its Widget is replaced (#6459)
* widget: rebuild form item render slot when its Widget is replaced ensureRenderItems() only ever appended render objects for newly added Items, so replacing an existing FormItem.Widget after the form had already been rendered was silently ignored by Refresh(), unlike Text changes which are applied in place by updateLabels(). Fixes #1966 * widget: reset stale per-widget FormItem state on widget swap Address review feedback on the previous commit: when an item's Widget is swapped, also detach the old widget's validation callbacks and reset validationError, invalid, wasFocused and helperOutput, so none of it leaks from the widget that used to occupy the slot.
1 parent 2ae9f90 commit 8860ee9

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

widget/form.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,31 @@ func (f *Form) createInput(item *FormItem) fyne.CanvasObject {
220220
return &fyne.Container{Layout: formItemLayout{form: f}, Objects: []fyne.CanvasObject{item.Widget, textContainer}}
221221
}
222222

223+
// unwrapItemWidget returns the widget actually shown by rendered, stripping the
224+
// hint/validation container createInput sometimes wraps it in.
225+
func (*Form) unwrapItemWidget(rendered fyne.CanvasObject) fyne.CanvasObject {
226+
if c, ok := rendered.(*fyne.Container); ok && len(c.Objects) > 0 {
227+
return c.Objects[0]
228+
}
229+
return rendered
230+
}
231+
232+
func (f *Form) itemRendersWidget(rendered, widget fyne.CanvasObject) bool {
233+
return f.unwrapItemWidget(rendered) == widget
234+
}
235+
236+
// detachValidation unhooks the callbacks setUpValidation registered on widget, so a
237+
// caller that keeps interacting with a widget no longer shown by the form can't have
238+
// it write stale state into the FormItem slot it used to occupy.
239+
func (*Form) detachValidation(widget fyne.CanvasObject) {
240+
if v, ok := widget.(fyne.Validatable); ok {
241+
v.SetOnValidationChanged(nil)
242+
}
243+
if r, ok := widget.(fyne.Requireable); ok {
244+
r.SetOnRequiredChanged(nil)
245+
}
246+
}
247+
223248
func (*Form) itemWidgetHasValidator(w fyne.CanvasObject) bool {
224249
value := reflect.ValueOf(w).Elem()
225250
validatorField := value.FieldByName("Validator")
@@ -327,6 +352,23 @@ func (f *Form) checkValidation(err error) {
327352

328353
func (f *Form) ensureRenderItems() {
329354
done := len(f.itemGrid.Objects) / 2
355+
for i := 0; i < done && i < len(f.Items); i++ {
356+
item := f.Items[i]
357+
old := f.itemGrid.Objects[i*2+1]
358+
if f.itemRendersWidget(old, item.Widget) {
359+
continue
360+
}
361+
362+
f.detachValidation(f.unwrapItemWidget(old))
363+
item.validationError = nil
364+
item.invalid = false
365+
item.wasFocused = false
366+
item.helperOutput = nil
367+
368+
f.setUpValidation(item.Widget, i)
369+
f.itemGrid.Objects[i*2+1] = f.createInput(item)
370+
}
371+
330372
if done >= len(f.Items) {
331373
f.itemGrid.Objects = f.itemGrid.Objects[0 : len(f.Items)*2]
332374
return

widget/form_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,95 @@ func TestForm_ChangeText(t *testing.T) {
142142
assert.Equal(t, "Changed", c.Objects[0].(*RichText).String())
143143
}
144144

145+
func TestForm_ChangeItemWidget(t *testing.T) {
146+
oldEntry := NewEntry()
147+
item := NewFormItem("Test", oldEntry)
148+
form := NewForm(item)
149+
test.TempWidgetRenderer(t, form)
150+
151+
assert.Equal(t, oldEntry, form.itemGrid.Objects[1])
152+
153+
newEntry := NewEntry()
154+
item.Widget = newEntry
155+
form.Refresh()
156+
157+
assert.Equal(t, newEntry, form.itemGrid.Objects[1])
158+
}
159+
160+
func TestForm_ChangeItemWidget_ResetsValidation(t *testing.T) {
161+
test.NewTempApp(t)
162+
163+
invalidEntry := &Entry{Validator: validation.NewRegexp(`^\d+$`, "must be numeric"), Text: "not-a-number"}
164+
item := NewFormItem("Test", invalidEntry)
165+
form := &Form{Items: []*FormItem{item}, OnSubmit: func() {}}
166+
test.NewTempWindow(t, form)
167+
168+
assert.True(t, item.invalid)
169+
assert.Error(t, item.validationError)
170+
assert.True(t, form.submitButton.Disabled())
171+
172+
item.Widget = NewLabel("static") // not fyne.Validatable
173+
form.Refresh()
174+
175+
assert.False(t, item.invalid)
176+
assert.NoError(t, item.validationError)
177+
assert.False(t, form.submitButton.Disabled())
178+
}
179+
180+
func TestForm_ChangeItemWidget_ResetsWasFocused(t *testing.T) {
181+
test.NewTempApp(t)
182+
183+
entry := &Entry{}
184+
item := &FormItem{Text: "Test", Widget: entry, HintText: "a hint"}
185+
form := &Form{Items: []*FormItem{item}}
186+
test.NewTempWindow(t, form)
187+
188+
entry.FocusGained()
189+
entry.FocusLost()
190+
form.Refresh()
191+
assert.True(t, item.wasFocused)
192+
193+
item.Widget = &Entry{}
194+
form.Refresh()
195+
196+
assert.False(t, item.wasFocused)
197+
}
198+
199+
func TestForm_ChangeItemWidget_DetachesOldWidget(t *testing.T) {
200+
test.NewTempApp(t)
201+
202+
oldEntry := &Entry{Validator: validation.NewRegexp(`^\d+$`, "must be numeric")}
203+
item := NewFormItem("Test", oldEntry)
204+
form := &Form{Items: []*FormItem{item}, OnSubmit: func() {}}
205+
test.NewTempWindow(t, form)
206+
207+
item.Widget = &Entry{}
208+
form.Refresh()
209+
210+
// oldEntry is no longer part of the form; validating it must not write into item.
211+
oldEntry.Text = "not-a-number"
212+
oldEntry.Validate()
213+
214+
assert.False(t, item.invalid)
215+
assert.NoError(t, item.validationError)
216+
}
217+
218+
func TestForm_ChangeItemWidget_ClearsHelperOutput(t *testing.T) {
219+
test.NewTempApp(t)
220+
221+
entry := &Entry{Validator: validation.NewRegexp(`^\d+$`, "must be numeric"), Text: "not-a-number"}
222+
item := NewFormItem("Test", entry)
223+
form := &Form{Items: []*FormItem{item}, OnSubmit: func() {}}
224+
test.NewTempWindow(t, form)
225+
226+
assert.NotNil(t, item.helperOutput)
227+
228+
item.Widget = NewLabel("static")
229+
form.Refresh()
230+
231+
assert.Nil(t, item.helperOutput)
232+
}
233+
145234
func TestForm_ChangeTheme(t *testing.T) {
146235
test.NewTempApp(t)
147236

0 commit comments

Comments
 (0)