From 20e786985a435be0866447c20761258ac7dd6d52 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Mon, 16 Sep 2024 00:48:23 +0200 Subject: [PATCH] Added reactions animation. --- Example/AblyChatExample/ContentView.swift | 204 ++++++++++++------ .../Mocks/MockSubscriptions.swift | 2 +- Example/AblyChatExample/Mocks/Mocks.swift | 2 +- 3 files changed, 146 insertions(+), 62 deletions(-) diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 2d372307..bb6d28a1 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -3,7 +3,15 @@ import SwiftUI @MainActor struct ContentView: View { - + +#if os(macOS) + let screenWidth = NSScreen.main?.frame.width ?? 500 + let screenHeight = NSScreen.main?.frame.height ?? 500 +#else + let screenWidth = UIScreen.main.bounds.width + let screenHeight = UIScreen.main.bounds.height +#endif + @State private var chatClient = MockChatClient( realtime: MockRealtime.create(), clientOptions: ClientOptions() @@ -11,7 +19,7 @@ struct ContentView: View { @State private var title = "Room" @State private var messages = [BasicListItem]() - @State private var reactions = "" + @State private var reactions: [Reaction] = [] @State private var newMessage = "" @State private var typingInfo = "" @State private var occupancyInfo = "Connections: 0" @@ -26,73 +34,88 @@ struct ContentView: View { } var body: some View { - VStack { - Text(title) - .font(.headline) - .padding(5) - HStack { - Text("") - Text(occupancyInfo) - Text(statusInfo) - } - .font(.footnote) - .frame(height: 12) - .padding(.horizontal, 8) - Text(reactions) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(5) - List(messages, id: \.id) { item in - MessageBasicView(item: item) - .flip() - } - .flip() - .listStyle(PlainListStyle()) - HStack { - TextField("Type a message...", text: $newMessage) + ZStack { + VStack { + Text(title) + .font(.headline) + .padding(5) + HStack { + Text("") + Text(occupancyInfo) + Text(statusInfo) + } + .font(.footnote) + .frame(height: 12) + .padding(.horizontal, 8) + List(messages, id: \.id) { item in + MessageBasicView(item: item) + .flip() + } + .flip() + .listStyle(PlainListStyle()) + HStack { + TextField("Type a message...", text: $newMessage) #if !os(tvOS) - .textFieldStyle(RoundedBorderTextFieldStyle()) + .textFieldStyle(RoundedBorderTextFieldStyle()) #endif - - Button(action: { - if newMessage.isEmpty { - Task { - try await sendReaction(type: ReactionType.like.rawValue) - } - } else { - Task { - try await sendMessage() + Button(action: { + if newMessage.isEmpty { + Task { + try await sendReaction(type: ReactionType.like.rawValue) + } + } else { + Task { + try await sendMessage() + } } - } - }) { + }) { #if os(iOS) - Text(sendTitle) - .foregroundColor(.white) - .padding(.vertical, 6) - .padding(.horizontal, 12) - .background(Color.blue) - .cornerRadius(15) + Text(sendTitle) + .foregroundColor(.white) + .padding(.vertical, 6) + .padding(.horizontal, 12) + .background(Color.blue) + .cornerRadius(15) #else - Text(sendTitle) + Text(sendTitle) #endif + } } + .padding(.horizontal, 12) + HStack { + Text(typingInfo) + .font(.footnote) + Spacer() + } + .frame(height: 12) + .padding(.horizontal, 14) + .padding(.bottom, 5) } - .padding(.horizontal, 12) - HStack { - Text(typingInfo) - .font(.footnote) - Spacer() + ForEach(reactions) { reaction in + Text(reaction.emoji) + .font(.largeTitle) + .position(x: reaction.xPosition, y: reaction.yPosition) + .scaleEffect(reaction.scale) + .opacity(reaction.opacity) + .rotationEffect(.degrees(reaction.rotationAngle)) + .onAppear { + withAnimation(.easeOut(duration: reaction.duration)) { + moveReactionUp(reaction: reaction) + } + // Start rotation animation + withAnimation(Animation.linear(duration: reaction.duration).repeatForever(autoreverses: false)) { + startRotation(reaction: reaction) + } + } } - .frame(height: 12) - .padding(.horizontal, 14) - .padding(.bottom, 5) - .task { await showMessages() } - .task { await showReactions() } - .task { await showPresence() } - .task { await showTypings() } - .task { await showOccupancy() } - .task { await showRoomStatus() } - .task { await setDefaultTitle() } } + .task { await showMessages() } + .task { await showReactions() } + .task { await showPresence() } + .task { await showTypings() } + .task { await showOccupancy() } + .task { await showRoomStatus() } + .task { await setDefaultTitle() } } func setDefaultTitle() async { @@ -110,7 +133,7 @@ struct ContentView: View { func showReactions() async { for await reaction in await room().reactions.subscribe(bufferingPolicy: .unbounded) { withAnimation { - reactions.append(reaction.displayedText) + showReaction(reaction.displayedText) } } } @@ -176,6 +199,67 @@ struct ContentView: View { } } +extension ContentView { + + struct Reaction: Identifiable { + let id: UUID + let emoji: String + var xPosition: CGFloat + var yPosition: CGFloat + var scale: CGFloat + var opacity: Double + var rotationAngle: Double // New: stores the current rotation angle + var rotationSpeed: Double // New: stores the random rotation speed + var duration: Double + } + + func showReaction(_ emoji: String) { + let screenWidth = screenWidth + let centerX = screenWidth / 2 + + // Reduce the spread to 1/5th of the screen width + let reducedSpreadRange = screenWidth / 5 + + // Random x position now has a smaller range, centered around the middle of the screen + let startXPosition = CGFloat.random(in: centerX - reducedSpreadRange...centerX + reducedSpreadRange) + let randomRotationSpeed = Double.random(in: 30...360) // Random rotation speed + let duration = Double.random(in: 2...4) + + let newReaction = Reaction( + id: UUID(), + emoji: emoji, + xPosition: startXPosition, + yPosition: screenHeight - 100, + scale: 1.0, + opacity: 1.0, + rotationAngle: 0, // Initial angle + rotationSpeed: randomRotationSpeed, + duration: duration + ) + + reactions.append(newReaction) + + // Remove the reaction after the animation completes + DispatchQueue.main.asyncAfter(deadline: .now() + duration) { + reactions.removeAll { $0.id == newReaction.id } + } + } + + func moveReactionUp(reaction: Reaction) { + if let index = reactions.firstIndex(where: { $0.id == reaction.id }) { + reactions[index].yPosition = 0 // Move it to the top of the screen + reactions[index].scale = 0.5 // Shrink + reactions[index].opacity = 0.5 // Fade out + } + } + + func startRotation(reaction: Reaction) { + if let index = reactions.firstIndex(where: { $0.id == reaction.id }) { + reactions[index].rotationAngle += 360 // Continuous rotation over time + } + } +} + struct BasicListItem { var id: String var title: String diff --git a/Example/AblyChatExample/Mocks/MockSubscriptions.swift b/Example/AblyChatExample/Mocks/MockSubscriptions.swift index cf870da1..c23319fe 100644 --- a/Example/AblyChatExample/Mocks/MockSubscriptions.swift +++ b/Example/AblyChatExample/Mocks/MockSubscriptions.swift @@ -19,7 +19,7 @@ struct MockSubscription: Sendable, AsyncSequence { mergedSequence.makeAsyncIterator() } - init(randomElement: @escaping @Sendable () -> Element, interval: UInt64) { + init(randomElement: @escaping @Sendable () -> Element, interval: Double) { let (stream, continuation) = AsyncStream.makeStream(of: Element.self, bufferingPolicy: .unbounded) self.continuation = continuation let timer: AsyncTimerSequence = .init(interval: .seconds(interval), clock: .init()) diff --git a/Example/AblyChatExample/Mocks/Mocks.swift b/Example/AblyChatExample/Mocks/Mocks.swift index 8dac5b69..dbeedaf5 100644 --- a/Example/AblyChatExample/Mocks/Mocks.swift +++ b/Example/AblyChatExample/Mocks/Mocks.swift @@ -152,7 +152,7 @@ actor MockRoomReactions: RoomReactions { createdAt: Date(), clientID: self.clientID, isSelf: false) - }, interval: 1) + }, interval: Double.random(in: 0.1...0.5)) } func send(params: SendReactionParams) async throws {