用 SwiftUI 做个终端

最近用 SwiftUI 做了一个 Terminal 终端,挺有意思的,分享下实现思路。

效果大概是这样:

设计规范

这里可以参考 Developer 网站上的 Figma 资源来查看具体每个元素的宽高和间距等信息。

这样可以尽最大可能的还原 macOS 的风格。

红绿灯每一个直径12

横向间距8

窗口标题 SF Pro 13号字 Semibold(macOS) bold(iOS)

对应 macOS 下 body 字体,iOS下 footnote 字体

代码实现

思路是:构建标题栏 -> 文本混排+实现光标闪动 -> 接收输入

构建标题栏

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import SwiftUI

struct TrafficLights: View {
var body: some View {
HStack(spacing: 8) {
let colors: [Color] = [.red, .yellow, .green]
ForEach(colors, id: \.self) {
Circle()
.foregroundStyle($0)
.frame(width: 12)
.overlay {
Circle()
.stroke(Color.black.opacity(0.2), lineWidth: 0.5)
}
}
}
}
}
1
@Environment(\.colorScheme) private var colorScheme
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TrafficLights()
.frame(maxWidth: .infinity, alignment: .leading)
.overlay {
Text("username — -zsh — 100x30")
.font(.footnote.weight(.bold))
}
.padding(8)
.background(.bar)
.overlay {
GeometryReader { proxy in
UnevenRoundedRectangle(topLeadingRadius: 8, topTrailingRadius: 8)
.stroke(.foreground.opacity(colorScheme == .dark ? 0.15 : 0.3), lineWidth: 0.33)
.frame(height: proxy.size.height + 1)
}
}
.clipped()

这里让高度+1然后clipped的原因是:等会儿我们的内容区域也要这么加一个描边,为了防止两个描边重叠导致颜色加深,我们忽略 titlebar 的底部描边,保留内容区域的顶部描边。


2023.7.25 更新

这里的实现存在问题,当窗口标题文本过长时会和🚥重叠。

仔细观察macOS原生的实现,可以发现其表现为:

  • 空间充足时:居中显示标题
  • 空间不足时:优先靠右显示
  • 空间过小时:裁切文本显示省略号,仍保持右侧8个像素的间距

我认为这里的难点在于:要动态的修改标题的对齐方式。

我的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
struct WindowTitleBar<Title: View>: View {
@ViewBuilder var title: () -> Title

@Environment(\.colorScheme) private var colorScheme

var body: some View {
HStack(spacing: 0) {
TrafficLights().padding(8)
Spacer(minLength: 0)
title()
.font(.footnote.bold())
.layoutPriority(1)
Spacer(minLength: 0)
Color.clear // 52 = 12(red) + 8(spacing) + 12(green) + 8 + 12(yellow)
.frame(minWidth: 8, maxWidth: 52 + 8 * 2)
}
.frame(maxWidth: .infinity)
.frame(height: 28)
.lineLimit(1)
.font(.footnote.bold())
.background(.bar)
.overlay {
GeometryReader { proxy in
UnevenRoundedRectangle(topLeadingRadius: 8, topTrailingRadius: 8)
.stroke(.foreground.opacity(colorScheme == .dark ? 0.15 : 0.3), lineWidth: 0.33)
.frame(height: proxy.size.height + 1)
}
}
.clipped()
}
}

解释下原理(从左到右):

  1. TrafficLights().padding(8) 略。
  2. Spacer(minLength: 0) 两个成对用于居中标题文本。
  3. .layoutPriority(1) 让标题布局优先级更高,在空间不足时把优先布局标题。
  4. Color.clear 右侧边距,与 TrafficLights() 保持一致宽度,确保在空间充足时相对于整个标题栏(包括左侧🚥)居中,若空间不足,也要始终保持宽度为8,留出与左侧相同的间距。

文本混排+实现光标闪动

文本混排比较简单,使用多个 Text 串联即可,在结尾加上光标(▉) 。

1
2
3
4
5
6
7
8
9
10
11
12
Group {
Text("Login at: \(Date.now.formatted()).\n")
+
Text("▉")
}
.monospaced() // Match terminal feel.
.padding(4)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.overlay {
UnevenRoundedRectangle(bottomLeadingRadius: 8, bottomTrailingRadius: 8)
.stroke(.foreground.opacity(colorScheme == .dark ? 0.15 : 0.3), lineWidth: 0.33)
}

这里就是我们的内容区域,同样需要加上描边,我们使用 SwiftUI 5 新增的 UnevenRoundedRectangle 来控制四周圆角

光标闪动就是让光标以固定的时间间隔改变样式,在文本混排中,可以使用 .foregroundColor(iOS 17之前) / .foregroundStyle(iOS 17+) 来对文本颜色、样式进行调整。

参考 HIG,可以这么修改:

1
2
3
4
5
6
7
8
9
10
enum CursorStyle {
case transparent, opaque

mutating func toggle() {
switch self {
case .opaque: self = .transparent
case .transparent: self = .opaque
}
}
}
1
@State private var cursorStyle = CursorStyle.opaque
1
2
3
4
5
Group {
...
Text("▉").foregroundStyle(cursorStyle == .transparent ? .clear : Color(white: 0.45))
}
.task(priority: .background) { changeCursorStyle() }
1
2
3
4
5
6
7
private func changeCursorStyle() {
Task {
try? await Task.sleep(for: .seconds(0.5))
cursorStyle.toggle()
changeCursorStyle()
}
}

接收输入

这里使用 TextEditor,但是不能直接让它显示出来覆盖页面,且 Editor 内的文本也不要显示,且要保留点击获得焦点的能力。

最简单的方式就是 .opacity(0.001),搞定。

1
2
3
4
5
6
7
8
9
10
11
@State private var userInput = ""

Group {
...
Text(userInput)
...
}
...
.overlay {
TextEditor(text: $userInput).opacity(0.001)
}

还有一点要注意的是,我们要:

  • 禁用系统的自动改正(auto-correction):.autocorrectionDisabled()
  • 禁用自动首字母大写(auto-capitalization):.textInputAutocapitalization(.never)
  • 禁用文本预测(text-prediction):.keyboardType(.asciiCapable)

大功告成,接下来,你只需要根据自己需求来处理命令即可,我这里的方式是在文本变化时 .onChange(of: userInput) 检测最后一个字符是否为换行来决定是否执行,对于不同的命令如何执行这部分大家可以自己发挥想象。

存在的问题

由于 TextEditor 隐藏了,用户无法直接看到光标在哪里,这里的光标永远在最后,无法根据 TextEditor 中的光标位置移动,使用外置键盘可以修改光标位置,这时候会比较annoying😡

可以考虑使用 Selection Range in SwiftUI 来解决,但是由于是 Hack 方法,可能会导致其他的问题。

吐槽下 SwiftUI 到现在都没支持文本选择的范围。

也不知道 SwiftUI 5 的 TextEditorStyle 会不会给我们带来什么惊喜?

发过反馈:FB12340578,坐等。

作者

LiYanan

发布于

2023-07-23

更新于

2023-07-25

许可协议

评论