Skip to content
Snippets Groups Projects
Commit f705ce5c authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Prototype chat UI implementation

parent 7ba09aa7
No related branches found
No related tags found
2 merge requests!102Release 1.0.0,!87Messenger example - chat
......@@ -106,6 +106,7 @@ let package = Package(
.target(
name: "ChatFeature",
dependencies: [
.target(name: "AppCore"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
],
swiftSettings: swiftSettings
......
// 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
// 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
......@@ -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 {
......
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: ()
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment