NSGo

入门

示例:待办应用

用 signal、列表和测试,一步一步构建一个小型待办应用。

本教程构建一个小型待办应用:添加条目、勾选完成、删除条目。大约十分钟可以完成,开始前先完成环境准备。

创建应用

mkdir todo && cd todo
go mod init example.com/todo
go get github.com/oneo-me/nsgo
go get -tool github.com/oneo-me/nsgo/cmd/nsgo
go tool nsgo init
go tool nsgo dev

构建待办应用

生成的应用是一个计数器。把 app.go 的全部内容替换为:

package main

import (
	"fmt"
	"strings"

	"github.com/oneo-me/nsgo"
	"github.com/oneo-me/nsgo/core"
	"github.com/oneo-me/nsgo/icons/lucide"
	"github.com/oneo-me/nsgo/ui"
)

type todo struct {
	id   int
	text string
	done bool
}

func main() { nsgo.Run(Root) }

func Root(cx *core.Scope) core.Node {
	items := core.NewSignalFunc(cx, []todo{}, nil)
	draft := core.NewSignal(cx, "")
	nextID := core.NewSignal(cx, 1)
	colors := ui.ColorsOf(cx)

	add := func() {
		text := strings.TrimSpace(draft.Get())
		if text == "" {
			return
		}
		id := nextID.Get()
		nextID.Set(id + 1)
		items.Update(func(list []todo) []todo {
			return append(list, todo{id: id, text: text})
		})
		draft.Set("")
	}
	setDone := func(id int, done bool) {
		items.Update(func(list []todo) []todo {
			next := append([]todo(nil), list...)
			for i := range next {
				if next[i].id == id {
					next[i].done = done
				}
			}
			return next
		})
	}
	remove := func(id int) {
		items.Update(func(list []todo) []todo {
			out := list[:0:0]
			for _, it := range list {
				if it.id != id {
					out = append(out, it)
				}
			}
			return out
		})
	}
	left := func() int {
		n := 0
		for _, it := range items.Get() {
			if !it.done {
				n++
			}
		}
		return n
	}

	return core.View{
		Style: core.Style{FlexGrow: 1, FlexShrink: 1, Padding: core.All(24), PaddingEnv: core.EnvAll(core.EnvSafeArea), Gap: 16},
		StyleFunc: func() core.Style {
			return core.Style{Background: colors.Background()}
		},
		Children: []core.Node{
			ui.Text("待办", 28, ui.Bold, colors.Text),
			core.View{Style: core.Style{Direction: core.Row, Gap: 8}, Children: []core.Node{
				ui.Input{Value: draft.Get, OnChange: draft.Set, Placeholder: "要做什么?",
					OnSubmit: func(string) { add() }, TestID: "draft", Style: core.Style{FlexGrow: 1}},
				ui.Button{Text: "添加", Icon: lucide.Plus, OnPress: add, TestID: "add"},
			}},
			ui.Textf(func() string { return fmt.Sprintf("%d 项未完成", left()) }, 13, ui.Regular, colors.Muted),
			core.List{
				Count:         func() int { return len(items.Get()) },
				Key:           func(i int) any { return items.Get()[i].id },
				EstimatedSize: 44,
				Style:         core.Style{FlexGrow: 1},
				Empty:         ui.Text("还没有待办,在上方添加一项。", 13, ui.Regular, colors.Muted),
				Row: func(cx *core.Scope, row core.ListRow) core.Node {
					index := row.Index()
					it := items.Get()[index]
					return core.View{Style: core.Style{Direction: core.Row, AlignItems: core.AlignCenter, Gap: 8, Padding: core.Symmetric(8, 0)},
						Children: []core.Node{
							ui.Checkbox{
								Checked: func() bool {
									for _, x := range items.Get() {
										if x.id == it.id {
											return x.done
										}
									}
									return false
								},
								OnChange: func(done bool) { setDone(it.id, done) },
								Text:     it.text,
								TestID:   fmt.Sprintf("todo-%d", it.id),
								Style:    core.Style{FlexGrow: 1, FlexShrink: 1},
							},
							ui.IconButton{Icon: lucide.Trash, Label: "删除", Variant: ui.IconButtonGhost,
								TestID: fmt.Sprintf("delete-%d", it.id), OnPress: func() { remove(it.id) }},
						}}
				},
			},
		},
	}
}

保存后应用会重新启动。输入一项任务,点击添加,再勾选它。

它是如何工作的

状态是 signal。 items、draft 和 nextID 都是 signal。signal 保存一个值并跟踪读取它的地方:items 变化时,只有读取它的文本和行会更新。core.NewSignal 用 == 比较值,因此切片要用没有相等函数的 core.NewSignalFunc。

更新会替换切片。 items.Update 返回一个新切片,列表的身份和行的 key 决定哪些内容留在屏幕上。nextID 给每个待办一个 ID,core.List 的 Key 使用它;没有稳定的 key,行会把自己的单元格复用给其他条目。

屏幕是一棵盒子树。 Root 返回一个 core.View,带内边距、间距和安全区域内边距。FlexGrow: 1 和 FlexShrink: 1 让它恰好填满窗口,所以长列表会在其中滚动,而不是把它撑出窗口边缘。输入行设置 Direction: core.Row,其余部分使用默认的纵向排列。

输入框是受控的。 draft.Get 是输入框的值,draft.Set 接收每次变化。按下回车时 OnSubmit 添加任务,add 清空草稿,因此输入框会自己清空。

列表只构建显示的部分。 core.List 按 Count 请求行,只在行接近屏幕时构建它。没有条目时显示 Empty,每一行的复选框和删除按钮带着待办的 ID,因此列表变化后按下时仍能找到正确的条目。

外观来自 ui。 ui.Text、ui.Input、ui.Button、ui.Checkbox 和 ui.IconButton 从主题取色,并自动跟随浅色和深色。ui.ColorsOf(cx) 给出应用自己绘制部分所需的颜色,StyleFunc 会在主题变化时原地跟随。

测试它

生成的测试按的是计数器的按钮,因此把 app_test.go 替换为待办应用的测试:

package main

import (
	"testing"
	"time"

	"github.com/oneo-me/nsgo/nsgotest"
)

func TestMain(m *testing.M) { nsgotest.Main(m) }

func TestAddCompleteAndDelete(t *testing.T) {
	app := nsgotest.Start(t, Root)
	app.Find("text:还没有待办,在上方添加一项。")
	app.Type("id:draft", "买牛奶")
	app.Press("id:add")
	app.WaitFor("text:买牛奶", time.Second)
	app.Press("id:todo-1")
	app.WaitFor("text:0 项未完成", time.Second)
	app.Press("id:delete-1")
	app.WaitFor("text:还没有待办,在上方添加一项。", time.Second)
}

测试以无窗口方式启动应用,按文本或 test ID 找到元素,并像用户一样操作。运行它:

go tool nsgo test

TestMain 在目标上运行测试时把主线程交给平台,go tool nsgo test -target ios_simulator 会用模拟器和它的原生输入运行同一个测试。

发布它

go tool nsgo build

build 为 nsgo.yaml 列出的每个目标写出发布包,放到 dist/<名称>-<版本>-<目标>:.app、.ipa、.apk、Windows 和 Linux 的目录,或一个网站。所有要交付出去的东西也都会放到 dist/releases:磁盘映像、安装程序、Debian 和 Arch 软件包、IPA、APK、网站、小程序,以及从你的 GitHub Releases 安装应用的脚本。构建与发布介绍安装程序、签名与公证、发布、图标、小组件、轻 App 和 App Store。

下一步

在 GitHub 上编辑此页