用SwiftUI开发ChatGPT应用程序(一)

关于ChatGPT

ChatGPT是OpenAI公司开发的一种全新聊天机器人模型,它能够通过学习和理解人类的语言来进行对话,还能根据聊天的上下文进行互动,真正像人类一样来聊天交流,甚至能完成撰写邮件、视频脚本、文案、翻译、代码,写论文等任务。

界面开发

先看看整体的界面效果:


效果图.png

这是一个macOS系统上的应用程序,界面分为两栏,左边是会话栏,显示我们创建的会话;右边是聊天界面,显示当前会话的内容,即我们跟ChatGPT的对话。点击侧栏按钮可以打开或关闭左边的会话栏。

效果图.gif

App入口:

@main
struct ChatgptApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

代码简析:
在SwiftUI中,我们的程序入口必须要遵循App协议,这样SwiftUI才能知道从哪个位置启动我们的应用。

多栏导航:

struct ContentView: View {
    var manager = SessionManager()
    
    var body: some View {
        NavigationSplitView {
            SessionView(manager: manager)
        } detail: {
            ChatView(manager: manager)
        }
    }
}

代码简析:
长久以来,开发者对 SwiftUI 的导航系统颇有微词,原本很基本功能,如跳转到根视图、返回视图栈中任意层级的视图等,开发者需要使用各种技巧乃至黑科技才能实现,让人不禁吐槽和抓狂。

SwiftUI 4.0( iOS 16+ 、macOS 13+ )对导航系统作出了重大改变,提供了以视图堆栈为管理对象的新 API ,让开发者可以轻松实现编程式导航。

其中最直接的变化是废弃了 NavigationView,将其功能分成了两个单独的控件 NavigationStack 和 NavigationSplitView。

会话栏界面:

会话栏.png

对应的代码:

struct SessionView: View {
    @ObservedObject var manager: SessionManager
    @State private var showAlert = false
    
    var body: some View {
        VStack {
            // 新建聊天
            Button {
                manager.createNewSession()
            } label: {
                Image("NewChat")
                    .resizable()
                    .frame(width: 12, height: 12)
                Spacer()
                    .frame(width: 8)
                Text("新建聊天")
                    .font(.system(size: 14))
            }
            .buttonStyle(.plain)
            .frame(height: 24)
            .keyboardShortcut("n")
            
            Spacer()
                .frame(height: 16)
            
            // 列表
            List(manager.sessions) { model in
                SessionCell(model: model)
                    .contentShape(Rectangle())
                    .onTapGesture {
                        manager.sessionId = model.id
                    }
                    .listRowBackground(manager.sessionId == model.id ? Color("ListColor") : Color.clear)
            }
            .background(.clear)
            .scrollContentBackground(.hidden)
            
            Spacer()
                .frame(height: 16)
            
            // 清除记录
            Button {
                showAlert = true
            } label: {
                Image("ClearRecord")
                    .resizable()
                    .frame(width: 12, height: 12)
                Spacer()
                    .frame(width: 8)
                Text("清除记录")
                    .font(.system(size: 14))
            }
            .frame(maxWidth: .infinity, maxHeight: 56)
            .buttonStyle(.plain)
            .background(Color("ListColor"))
        }
        .alert("您确定要清除所有会话记录吗", isPresented: $showAlert) {
            Button("清除", role: .destructive) {
                manager.removeAllSessions()
            }
            Button("取消", role: .cancel) {
                
            }
        }
    }
}

代码简析:
1、别看这段代码有点长,其实组成结构很简单,就是一个垂直布局(VStack),自上而下:Button(新建聊天)、List(会话列表)、Button(清除记录)。

2、关于contentShape(Rectangle())。SwiftUI的onTapGesture方法默认的点击区域是有效内容区域,即SessionCell中的图标和文字,其他空白区域是不响应点击的,所以我们需要用contentShape指定内容区域为整个矩形区。

3、关于keyboardShortcut。它是SwiftUI下的快捷键设置,比之前的cocoa框架设置快捷键的方式简单多了。

会话列表中的Cell:

会话栏Cell.png

对应的代码:

struct SessionCell: View {
    var model: SessionModel
    
    var body: some View {
        HStack {
            Image("Chat")
                .resizable()
                .frame(width: 14, height: 14)
            
            Text(model.title)
                .font(.system(size: 14))
            
            Spacer()
        }
        .padding(.vertical, 12)
    }
}

代码简析:
水平布局(HStack),自左向右:图片、文本。

聊天界面:

聊天界面.png

对应的代码:

struct ChatView: View {
    @ObservedObject var manager: SessionManager
    
    @State private var inputText = ""
    @State private var isInputEnable = true
    @FocusState private var isInputFocused: Bool
    
    var body: some View {
        // 聊天列表
        List(manager.chats) { model in
            ChatCell(model: model)
        }
        .background(
            Color("ListColor")
        )
        .scrollContentBackground(.hidden)
        .safeAreaInset(edge: .bottom) {
            // 输入框
            InputView(
                text: $inputText,
                isEnable: $isInputEnable,
                isFocused: $isInputFocused) {
                    manager.ask(prompt: inputText) { _ in
                        isInputEnable = true
                    } failure: { _ in
                        isInputEnable = true
                    }
                    inputText = ""
                    isInputFocused = false
                    isInputEnable = false
            }
        }
    }
}

代码简析:
整个聊天界面由列表和输入框组成,用safeAreaInset(edge: .bottom)将输入框覆盖在列表安全区域的位置。如果不喜欢这种覆盖的方式,使用垂直布局(VStack)也是可以的。

聊天列表中的Cell:

聊天列表中的Cell.png

对应的代码:

struct ChatCell: View {
    var model: ChatModel
    
    var body: some View {
        VStack(alignment: .leading) {
            HStack(spacing: 4) {
                Image(model.avatar)
                    .padding(.leading, 16)
                Text(model.name)
                    .foregroundColor(Color("NameColor"))
            }
            Text(model.content)
                .textSelection(.enabled)
                .font(.title3)
                .padding(.leading, 16)
            Spacer()
                .frame(height: 24)
        }
    }
}

代码简析:
这个界面使用了两种布局的组合:垂直布局(VStack)中嵌套了水平布局(HStack)。其实这种布局的嵌套在SwiftUI中很常用的,不可能一种布局就吃遍天下。SwiftUI目前的布局组件有五种:HStack、VStack、ZStack、LazyHStack、LazyVStack。

输入框界面:

输入框.png

对应的代码:

struct InputView: View {
    @Binding var text: String
    @Binding var isEnable: Bool
    var isFocused: FocusState<Bool>.Binding
    var onSubmit: () -> Void
    
    @State private var height: CGFloat = 64
    
    private let kFontSize: CGFloat = 16
    
    var body: some View {
        GeometryReader { proxy in
            ZStack {
                // 换行键
                Button("") {
                    text = text + "\n"
                }
                .hidden()
                .keyboardShortcut(.return)
                
                Rectangle()
                    .fill(Color("ListColor"))
                
                Rectangle()
                    .fill(fillColor())
                    .cornerRadius(24)
                    .padding(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
                    .overlay {
                        TextField("", text: $text, axis: .vertical)
                            .lineLimit(20)
                            .font(.system(size: kFontSize))
                            .textFieldStyle(.plain)
                            .disableAutocorrection(true)
                            .disabled(!isEnable)
                            .focused(isFocused)
                            .background(fillColor())
                            .padding(.horizontal, 36)
                            .onChange(of: text, perform: { newValue in
                                let paddingOffset = CGFloat(36 + 16*2) // TextField is 36, Rectangle is 16*2 (leading & trailing).
                                let size = CGSize(width: proxy.size.width - paddingOffset, height: .infinity)
                                let h = sizeToFit(with: text, in: size)
                                height = max(h + 44, 64)
                            })
                            .onSubmit {
                                onSubmit()
                            }
                    }
            }
        }
        .frame(height: height)
    }
    
    private func fillColor() -> Color {
        return isEnable ? Color("EnableInputColor") : Color("DisableInputColor")
    }
    
    private func sizeToFit(with string: String, in limitSize: CGSize) -> CGFloat {
        let range = NSMakeRange(0, string.count)
        let attributeString = NSMutableAttributedString(string: string)
        // 字体
        let font = NSFont.systemFont(ofSize: kFontSize)
        attributeString.addAttribute(.font, value: font, range: range)
        return attributeString.boundingRect(with: limitSize, options: [.usesFontLeading, .usesLineFragmentOrigin], context: nil).height
    }
}

代码简析:
1、关于ZStack。前面我们使用过了HStack和VStack,分别表示水平布局和垂直布局,但有时候光有这两个还不行,无法处理叠加式的布局,于是就有了ZStack。ZStack可以实现组件在屏幕方向上的层级叠加。

SwiftUI的三种布局.png

2、关于GeometryReader。苹果官方是这样定义的:一个可以根据其自身大小和坐标空间定义其内容的容器视图。看不懂,有点抽象。举个例子:视图中有一个按钮,如果在程序运行的时候我们想知道这个按钮的坐标和大小,该怎么办呢? GeometryReader 就是干这个事的,它带一个 GeometryProxy 参数,从这个参数就可以读取到坐标和大小的信息。

3、为了解决输入框随着文字的输入而自适应增高的问题,SwiftUI 4.0为TextField增加了一个axis参数,指定自增高的方向,有两个值:vertical 和 horizontal。

结语

以上就是用SwiftUI开发macOS系统的ChatGPT应用程序界面部分的代码,是不是很简单呢,哈哈。随着SwiftUI的不断优化,现在已经能胜任很多场景下的开发需求了,更少的代码还能跨平台,why not 用呢。

传送门:SwiftChatGPT

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容