From f705ce5c14bb768f7790a999912d39d4dfb32d39 Mon Sep 17 00:00:00 2001 From: Dariusz Rybicki <dariusz@elixxir.io> Date: Wed, 14 Sep 2022 01:34:35 +0200 Subject: [PATCH] Prototype chat UI implementation --- Examples/xx-messenger/Package.swift | 1 + .../SharedUI/GeometryReaderViewModifier.swift | 103 +++++++++ .../SharedUI/ToolbarViewModifier.swift | 213 ++++++++++++++++++ .../Sources/ChatFeature/ChatFeature.swift | 29 ++- .../Sources/ChatFeature/ChatView.swift | 100 +++++++- 5 files changed, 441 insertions(+), 5 deletions(-) create mode 100644 Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift create mode 100644 Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 2d5fbb9f..15e3c9a8 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -106,6 +106,7 @@ let package = Package( .target( name: "ChatFeature", dependencies: [ + .target(name: "AppCore"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), ], swiftSettings: swiftSettings diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift new file mode 100644 index 00000000..976b1ffe --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/GeometryReaderViewModifier.swift @@ -0,0 +1,103 @@ +// MIT License +// +// Copyright (c) 2022 Dariusz Rybicki Darrarski +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/GeometryReaderViewModifier.swift + +import SwiftUI + +extension View { + func geometryReader<Geometry: Codable>( + geometry: @escaping (GeometryProxy) -> Geometry, + onChange: @escaping (Geometry) -> Void + ) -> some View { + modifier(GeometryReaderViewModifier( + geometry: geometry, + onChange: onChange + )) + } +} + +struct GeometryReaderViewModifier<Geometry: Codable>: ViewModifier { + var geometry: (GeometryProxy) -> Geometry + var onChange: (Geometry) -> Void + + func body(content: Content) -> some View { + content + .background { + GeometryReader { geometryProxy in + Color.clear + .preference(key: GeometryPreferenceKey.self, value: { + let geometry = self.geometry(geometryProxy) + let data = try? JSONEncoder().encode(geometry) + return data + }()) + .onPreferenceChange(GeometryPreferenceKey.self) { data in + if let data = data, + let geomerty = try? JSONDecoder().decode(Geometry.self, from: data) + { + onChange(geomerty) + } + } + } + } + } +} + +struct GeometryPreferenceKey: PreferenceKey { + static var defaultValue: Data? = nil + + static func reduce(value: inout Data?, nextValue: () -> Data?) { + value = nextValue() + } +} + +#if DEBUG +struct GeometryReaderModifier_Previews: PreviewProvider { + struct Preview: View { + @State var size: CGSize = .zero + + var body: some View { + VStack { + Text("Hello, World!") + .font(.largeTitle) + .background(Color.accentColor.opacity(0.15)) + .geometryReader( + geometry: \.size, + onChange: { size = $0 } + ) + + Text("\(Int(size.width.rounded())) x \(Int(size.height.rounded()))") + .font(.caption) + .frame(width: size.width, height: size.height) + .background(Color.accentColor.opacity(0.15)) + } + } + } + + static var previews: some View { + Preview() +#if os(macOS) + .frame(width: 640, height: 480) +#endif + } +} +#endif diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift new file mode 100644 index 00000000..23d709d8 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/ToolbarViewModifier.swift @@ -0,0 +1,213 @@ +// MIT License +// +// Copyright (c) 2022 Dariusz Rybicki Darrarski +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +// Source: https://github.com/darrarski/swiftui-tabs-view/blob/be6865324ed9651c22df36540f932c10ab9c7c34/Sources/SwiftUITabsView/ToolbarViewModifier.swift + +import SwiftUI + +/// Describes position of the toolbar. +public enum ToolbarPosition: Equatable { + /// Bar positioned above the content. + case top + + /// Tabs bar positioned below the content. + case bottom + + var verticalEdge: VerticalEdge { + switch self { + case .top: return .top + case .bottom: return .bottom + } + } + + var frameAlignment: Alignment { + switch self { + case .top: return .top + case .bottom: return .bottom + } + } +} + +struct ToolbarPositionKey: EnvironmentKey { + static var defaultValue: ToolbarPosition = .bottom +} + +extension EnvironmentValues { + var toolbarPosition: ToolbarPosition { + get { self[ToolbarPositionKey.self] } + set { self[ToolbarPositionKey.self] = newValue } + } +} + +extension View { + public func toolbar<Bar: View>( + position: ToolbarPosition = .bottom, + ignoresKeyboard: Bool = true, + frameChangeAnimation: Animation? = .default, + @ViewBuilder bar: @escaping () -> Bar + ) -> some View { + modifier(ToolbarViewModifier( + ignoresKeyboard: ignoresKeyboard, + frameChangeAnimation: frameChangeAnimation, + bar: bar + )) + .environment(\.toolbarPosition, position) + } +} + +struct ToolbarViewModifier<Bar: View>: ViewModifier { + init( + ignoresKeyboard: Bool = true, + frameChangeAnimation: Animation? = .default, + @ViewBuilder bar: @escaping () -> Bar + ) { + self.ignoresKeyboard = ignoresKeyboard + self.frameChangeAnimation = frameChangeAnimation + self.bar = bar + } + + var ignoresKeyboard: Bool + var frameChangeAnimation: Animation? + var bar: () -> Bar + + @Environment(\.toolbarPosition) var position + @State var contentFrame: CGRect? + @State var toolbarFrame: CGRect? + @State var toolbarSafeAreaInset: CGSize = .zero + + var keyboardSafeAreaEdges: Edge.Set { + guard ignoresKeyboard else { return [] } + switch position { + case .top: return .top + case .bottom: return .bottom + } + } + + func body(content: Content) -> some View { + ZStack { + content + .frame(maxWidth: .infinity, maxHeight: .infinity) + .toolbarSafeAreaInset() + .geometryReader( + geometry: { $0.frame(in: .global) }, + onChange: { frame in + withAnimation(contentFrame == nil ? .none : frameChangeAnimation) { + contentFrame = frame + toolbarSafeAreaInset = makeToolbarSafeAreaInset() + } + } + ) + + bar() + .geometryReader( + geometry: { $0.frame(in: .global) }, + onChange: { frame in + withAnimation(toolbarFrame == nil ? .none : frameChangeAnimation) { + toolbarFrame = frame + toolbarSafeAreaInset = makeToolbarSafeAreaInset() + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: position.frameAlignment) + .ignoresSafeArea(.keyboard, edges: keyboardSafeAreaEdges) + } + .environment(\.toolbarSafeAreaInset, toolbarSafeAreaInset) + } + + func makeToolbarSafeAreaInset() -> CGSize { + guard let contentFrame = contentFrame, + let toolbarFrame = toolbarFrame + else { return .zero } + + var size = contentFrame.intersection(toolbarFrame).size + size.width = max(0, size.width) + size.height = max(0, size.height) + + return size + } +} + +struct ToolbarSafeAreaInsetKey: EnvironmentKey { + static var defaultValue: CGSize = .zero +} + +extension EnvironmentValues { + var toolbarSafeAreaInset: CGSize { + get { self[ToolbarSafeAreaInsetKey.self] } + set { self[ToolbarSafeAreaInsetKey.self] = newValue } + } +} + +struct ToolbarSafeAreaInsetViewModifier: ViewModifier { + @Environment(\.toolbarPosition) var position + @Environment(\.toolbarSafeAreaInset) var toolbarSafeAreaInset + + func body(content: Content) -> some View { + content + .safeAreaInset(edge: position.verticalEdge) { + Color.clear.frame( + width: toolbarSafeAreaInset.width, + height: toolbarSafeAreaInset.height + ) + } + } +} + +extension View { + /// Add safe area inset for toolbar. + /// + /// Use this modifier if your content is embedded in `NavigationView`. + /// Apply it on the content inside the `NavigationView`. + /// + /// - Returns: View with additional safe area insets matching the toolbar. + public func toolbarSafeAreaInset() -> some View { + modifier(ToolbarSafeAreaInsetViewModifier()) + } +} + +#if DEBUG +struct ToolbarViewModifier_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(1..<21) { row in + VStack(alignment: .leading, spacing: 0) { + Text("Row #\(row)") + TextField("Text", text: .constant("")) + } + .padding() + .background(Color.accentColor.opacity(row % 2 == 0 ? 0.1 : 0.15)) + } + } + } + .toolbar(ignoresKeyboard: true) { + Text("Bottom Bar") + .padding() + .frame(maxWidth: .infinity) + .background(.ultraThinMaterial) + } +#if os(macOS) + .frame(width: 640, height: 480) +#endif + } +} +#endif diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift index d95eabce..1b050a0a 100644 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatFeature.swift @@ -7,11 +7,38 @@ public struct ChatState: Equatable, Identifiable { case contact(Data) } - public init(id: ID) { + public struct Message: Equatable, Identifiable { + public init( + id: Data, + date: Date, + senderId: Data, + text: String + ) { + self.id = id + self.date = date + self.senderId = senderId + self.text = text + } + + public var id: Data + public var date: Date + public var senderId: Data + public var text: String + } + + public init( + id: ID, + myContactId: Data? = nil, + messages: IdentifiedArrayOf<Message> = [] + ) { self.id = id + self.myContactId = myContactId + self.messages = messages } public var id: ID + public var myContactId: Data? + public var messages: IdentifiedArrayOf<Message> } public enum ChatAction: Equatable { diff --git a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift index d3016743..0d2be0f0 100644 --- a/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift +++ b/Examples/xx-messenger/Sources/ChatFeature/ChatView.swift @@ -1,3 +1,4 @@ +import AppCore import ComposableArchitecture import SwiftUI @@ -9,13 +10,89 @@ public struct ChatView: View { let store: Store<ChatState, ChatAction> struct ViewState: Equatable { - init(state: ChatState) {} + var myContactId: Data? + var messages: IdentifiedArrayOf<ChatState.Message> + + init(state: ChatState) { + myContactId = state.myContactId + messages = state.messages + } } public var body: some View { WithViewStore(store, observe: ViewState.init) { viewStore in - Text("ChatView") - .task { viewStore.send(.start) } + ScrollView { + LazyVStack { + ForEach(viewStore.messages) { message in + MessageView( + message: message, + myContactId: viewStore.myContactId + ) + } + } + } + .toolbar( + position: .bottom, + ignoresKeyboard: true, + frameChangeAnimation: .default + ) { + VStack(spacing: 0) { + Divider() + HStack { + TextField("Text", text: .constant("")) + .textFieldStyle(.roundedBorder) + + Button { + + } label: { + Image(systemName: "paperplane.fill") + } + .buttonStyle(.borderedProminent) + } + .padding() + } + .background(Material.regularMaterial) + } + .navigationTitle("Chat") + .task { viewStore.send(.start) } + .toolbarSafeAreaInset() + } + } + + struct MessageView: View { + var message: ChatState.Message + var myContactId: Data? + + var alignment: Alignment { + message.senderId == myContactId ? .trailing : .leading + } + + var backgroundColor: Color { + message.senderId == myContactId ? Color.blue : Color.gray.opacity(0.5) + } + + var textColor: Color? { + message.senderId == myContactId ? Color.white : nil + } + + var body: some View { + VStack { + Text("\(message.date.formatted())") + .foregroundColor(.secondary) + .font(.footnote) + .frame(maxWidth: .infinity, alignment: alignment) + + Text(message.text) + .foregroundColor(textColor) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(backgroundColor) + } + .frame(maxWidth: .infinity, alignment: alignment) + } + .padding(.horizontal) } } } @@ -26,7 +103,22 @@ public struct ChatView_Previews: PreviewProvider { NavigationView { ChatView(store: Store( initialState: ChatState( - id: .contact("contact-id".data(using: .utf8)!) + id: .contact("contact-id".data(using: .utf8)!), + myContactId: "my-contact-id".data(using: .utf8)!, + messages: [ + .init( + id: "message-1-id".data(using: .utf8)!, + date: Date(), + senderId: "contact-id".data(using: .utf8)!, + text: "Hello!" + ), + .init( + id: "message-2-id".data(using: .utf8)!, + date: Date(), + senderId: "my-contact-id".data(using: .utf8)!, + text: "Hi!" + ), + ] ), reducer: .empty, environment: () -- GitLab