From 721e6f90b01b36d16789e602384619c9f233ba7d Mon Sep 17 00:00:00 2001 From: Stanley Jovel Date: Fri, 6 Dec 2024 12:38:35 -0600 Subject: [PATCH] Scroll to bottom when input text is selected --- OLMoE.swift.xcodeproj/project.pbxproj | 4 ++ OLMoE.swift/Model/KeyboardResponder.swift | 27 ++++++++++ OLMoE.swift/Views/ChatView.swift | 61 ++++++++++++++--------- OLMoE.swift/Views/ContentView.swift | 7 ++- 4 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 OLMoE.swift/Model/KeyboardResponder.swift diff --git a/OLMoE.swift.xcodeproj/project.pbxproj b/OLMoE.swift.xcodeproj/project.pbxproj index 13a3098..9d62d7f 100644 --- a/OLMoE.swift.xcodeproj/project.pbxproj +++ b/OLMoE.swift.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 489FD75C2CF13E4E0011E908 /* AttestManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 489FD75B2CF13E4E0011E908 /* AttestManager.swift */; }; 48B806352CF54D9F00E1CC0A /* LambdaResponseModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48B806342CF54D9600E1CC0A /* LambdaResponseModel.swift */; }; + 48E37C202D0271610030C57C /* KeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E37C1F2D02715B0030C57C /* KeyboardResponder.swift */; }; A0117FEC2C990EAB00035007 /* OLMoE_swiftApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0117FEB2C990EAB00035007 /* OLMoE_swiftApp.swift */; }; A0117FF02C990EAC00035007 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A0117FEF2C990EAC00035007 /* Assets.xcassets */; }; A0117FF42C990EAC00035007 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A0117FF32C990EAC00035007 /* Preview Assets.xcassets */; }; @@ -28,6 +29,7 @@ /* Begin PBXFileReference section */ 489FD75B2CF13E4E0011E908 /* AttestManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttestManager.swift; sourceTree = ""; }; 48B806342CF54D9600E1CC0A /* LambdaResponseModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LambdaResponseModel.swift; sourceTree = ""; }; + 48E37C1F2D02715B0030C57C /* KeyboardResponder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardResponder.swift; sourceTree = ""; }; A0117FE82C990EAB00035007 /* OLMoE.swift.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OLMoE.swift.app; sourceTree = BUILT_PRODUCTS_DIR; }; A0117FEB2C990EAB00035007 /* OLMoE_swiftApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OLMoE_swiftApp.swift; sourceTree = ""; }; A0117FEF2C990EAC00035007 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -119,6 +121,7 @@ B26FDB402CEAD47700E1D57C /* Model */ = { isa = PBXGroup; children = ( + 48E37C1F2D02715B0030C57C /* KeyboardResponder.swift */, 48B806342CF54D9600E1CC0A /* LambdaResponseModel.swift */, 489FD75B2CF13E4E0011E908 /* AttestManager.swift */, A01180002C990F7600035007 /* LLM.swift */, @@ -232,6 +235,7 @@ B26FDB502CEAD54400E1D57C /* Template.swift in Sources */, B26FDB4E2CEAD53E00E1D57C /* Quantization.swift in Sources */, B26FDB4A2CEAD53000E1D57C /* HuggingFaceModel.swift in Sources */, + 48E37C202D0271610030C57C /* KeyboardResponder.swift in Sources */, 48B806352CF54D9F00E1CC0A /* LambdaResponseModel.swift in Sources */, A046603F2CA400D2007BD14C /* AppInfo.swift in Sources */, ); diff --git a/OLMoE.swift/Model/KeyboardResponder.swift b/OLMoE.swift/Model/KeyboardResponder.swift new file mode 100644 index 0000000..5b6ad20 --- /dev/null +++ b/OLMoE.swift/Model/KeyboardResponder.swift @@ -0,0 +1,27 @@ +// +// KeyboardResponder.swift +// OLMoE.swift +// +// Created by Stanley Jovel on 12/5/24. +// + +import Combine +import SwiftUI + +final class KeyboardResponder: ObservableObject { + @Published var keyboardHeight: CGFloat = 0 + private var cancellables = Set() + + init() { + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .compactMap { $0.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect } + .map { $0.height } + .assign(to: \.keyboardHeight, on: self) + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .map { _ in CGFloat(0) } + .assign(to: \.keyboardHeight, on: self) + .store(in: &cancellables) + } +} diff --git a/OLMoE.swift/Views/ChatView.swift b/OLMoE.swift/Views/ChatView.swift index bbf2087..7e144df 100644 --- a/OLMoE.swift/Views/ChatView.swift +++ b/OLMoE.swift/Views/ChatView.swift @@ -103,36 +103,51 @@ public struct ChatView: View { @Binding var isGenerating: Bool @Binding var isScrolledToBottom: Bool @State private var scrollState = ScrollState() - + @StateObject private var keyboardResponder = KeyboardResponder() + @State var id = UUID() + public var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 10) { - // History - ForEach(history) { chat in - if !chat.content.isEmpty { - switch chat.role { - case .user: - UserChatBubble(text: chat.content) - case .bot: - BotChatBubble(text: chat.content) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 10) { + // History + ForEach(history) { chat in + if !chat.content.isEmpty { + switch chat.role { + case .user: + UserChatBubble(text: chat.content) + case .bot: + BotChatBubble(text: chat.content) + } } } + + // Current output + if isGenerating { + BotChatBubble(text: output, isGenerating: isGenerating) + } + + Color.clear.frame(height: 1).id(ChatView.BottomID) } - - // Current output - if isGenerating { - BotChatBubble(text: output, isGenerating: isGenerating) + .font(.body.monospaced()) + .foregroundColor(Color("TextColor")) + .background(scrollTracker()) + } + .background(scrollHeightTracker()) + .coordinateSpace(name: ScrollState.ScrollSpaceName) + .preferredColorScheme(.dark) + .onChange(of: keyboardResponder.keyboardHeight) { _,newHeight in + let keyboardIsVisible = newHeight > 0 + if keyboardIsVisible { + id = UUID() // Trigger refresh by changing the id } - - Color.clear.frame(height: 1).id(ChatView.BottomID) } - .font(.body.monospaced()) - .foregroundColor(Color("TextColor")) - .background(scrollTracker()) + .onAppear() { + // Scroll on refresh + proxy.scrollTo(ChatView.BottomID, anchor: .bottom) + } + .id(id) } - .background(scrollHeightTracker()) - .coordinateSpace(name: ScrollState.ScrollSpaceName) - .preferredColorScheme(.dark) } @ViewBuilder diff --git a/OLMoE.swift/Views/ContentView.swift b/OLMoE.swift/Views/ContentView.swift index 70dca72..1c9cbf1 100644 --- a/OLMoE.swift/Views/ContentView.swift +++ b/OLMoE.swift/Views/ContentView.swift @@ -267,7 +267,12 @@ struct BotView: View { if !isChatEmpty { ScrollViewReader { proxy in ZStack { - ChatView(history: bot.history, output: bot.output.trimmingCharacters(in: .whitespacesAndNewlines), isGenerating: $isGenerating, isScrolledToBottom: $isScrolledToBottom) + ChatView( + history: bot.history, + output: bot.output.trimmingCharacters(in: .whitespacesAndNewlines), + isGenerating: $isGenerating, + isScrolledToBottom: $isScrolledToBottom + ) .onChange(of: bot.output) { _, _ in if isScrolledToBottom { withAnimation {