From e7cfa6a5556a5183ce15906d8b8582b2fef89152 Mon Sep 17 00:00:00 2001 From: Mert Tecimen Date: Sun, 7 Aug 2022 16:44:51 +0300 Subject: [PATCH] Finished challenge. --- .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../Color + Extentions.swift | 23 +++ .../Constants.swift | 20 +++ .../ContentView + ViewModel.swift | 17 ++ .../ContentView.swift | 39 ++++ .../MyApp.swift | 10 ++ .../Package.swift | 43 +++++ .../PersonBadgeView + ViewModel.swift | 60 +++++++ .../PersonBadgeView.swift | 148 ++++++++++++++++ .../UserIconView.swift | 167 ++++++++++++++++++ 11 files changed, 542 insertions(+) create mode 100644 Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Airdrop Progress Animation.swiftpm/Color + Extentions.swift create mode 100644 Airdrop Progress Animation.swiftpm/Constants.swift create mode 100644 Airdrop Progress Animation.swiftpm/ContentView + ViewModel.swift create mode 100644 Airdrop Progress Animation.swiftpm/ContentView.swift create mode 100644 Airdrop Progress Animation.swiftpm/MyApp.swift create mode 100644 Airdrop Progress Animation.swiftpm/Package.swift create mode 100644 Airdrop Progress Animation.swiftpm/PersonBadgeView + ViewModel.swift create mode 100644 Airdrop Progress Animation.swiftpm/PersonBadgeView.swift create mode 100644 Airdrop Progress Animation.swiftpm/UserIconView.swift diff --git a/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Airdrop Progress Animation.swiftpm/Color + Extentions.swift b/Airdrop Progress Animation.swiftpm/Color + Extentions.swift new file mode 100644 index 0000000..d01be5c --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/Color + Extentions.swift @@ -0,0 +1,23 @@ +// +// Color + Extentions.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 4.08.2022. +// + +import SwiftUI + +extension Color{ + + public static let badgeBackgroundColor: Color = .init(red: 154/255, green: 154/255, blue: 154/255) + + public static let progressRingAccentColor: Color = .init(red: 20/255, green: 126/255, blue: 255/255) + + public static let progressRingBackgroundColor: Color = .init(red: 104/255, green: 103/255, blue: 108/255) + + public static let backgroundColor: Color = .init(red: 32/255, green: 32/255, blue: 32/255) + + public static let textColor: Color = .init(red: 220/255, green: 220/255, blue: 220/255) + + +} diff --git a/Airdrop Progress Animation.swiftpm/Constants.swift b/Airdrop Progress Animation.swiftpm/Constants.swift new file mode 100644 index 0000000..dc98e0d --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/Constants.swift @@ -0,0 +1,20 @@ +// +// Constants.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 4.08.2022. +// + +import Foundation + +enum States{ + case idle, waiting, sending, sent +} + +enum Device: String, CaseIterable{ + case iphone = "iPhone" + case macbook = "MacBook" + case iPad = "iPad" +} + + diff --git a/Airdrop Progress Animation.swiftpm/ContentView + ViewModel.swift b/Airdrop Progress Animation.swiftpm/ContentView + ViewModel.swift new file mode 100644 index 0000000..5356c7c --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/ContentView + ViewModel.swift @@ -0,0 +1,17 @@ +// +// ContentView + ViewModel.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 4.08.2022. +// + +import Foundation + + +extension ContentView{ + class ViewModel: ObservableObject{ + @Published var state: States = .idle + //let users: [User] = [.init(id: UUID(), name: "Moe")] + let users: [User] = [.init(id: UUID(), name: "Moe"), .init(id: UUID(), name: "Homer"), .init(id: UUID(), name: "Apu"), .init(id: UUID(), name: "Lisa")] + } +} diff --git a/Airdrop Progress Animation.swiftpm/ContentView.swift b/Airdrop Progress Animation.swiftpm/ContentView.swift new file mode 100644 index 0000000..5cd36c5 --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/ContentView.swift @@ -0,0 +1,39 @@ +import SwiftUI + + +// On the Content View, view model contains dummy data that represents AirDrop users; PersonBagde (AirDrop) views are displayed on 3 column lazyVGrid. + +struct ContentView: View { + + @State private var progress: CGFloat = 0 + @StateObject private var viewModel = ViewModel() + + + private var threeColumnGrid = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())] + + var body: some View { + VStack { + Text("Tap on individual icon to start animation. Longpress to reset animation for individual icon.") + .fontWeight(.light) + .foregroundColor(.textColor) + .padding([.all], 10) + LazyVGrid(columns: threeColumnGrid, spacing: 50) { + ForEach(viewModel.users){ user in + VStack { + PersonBadgeView(user: user) + // Aspect ratio setted to 1 to make circular icon even on height and width. + .aspectRatio(1.0, contentMode: .fit) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 100, maxHeight: .infinity) + } + } + .padding(.all, 10) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background{ + Color.backgroundColor + .ignoresSafeArea(.container) + } + } +} diff --git a/Airdrop Progress Animation.swiftpm/MyApp.swift b/Airdrop Progress Animation.swiftpm/MyApp.swift new file mode 100644 index 0000000..7cb2ea6 --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/MyApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct MyApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Airdrop Progress Animation.swiftpm/Package.swift b/Airdrop Progress Animation.swiftpm/Package.swift new file mode 100644 index 0000000..873ced9 --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/Package.swift @@ -0,0 +1,43 @@ +// swift-tools-version: 5.6 + +// WARNING: +// This file is automatically generated. +// Do not edit it by hand because the contents will be replaced. + +import PackageDescription +import AppleProductTypes + +let package = Package( + name: "Airdrop Progress Animation", + platforms: [ + .iOS("15.2") + ], + products: [ + .iOSApplication( + name: "Airdrop Progress Animation", + targets: ["AppModule"], + bundleIdentifier: "Mert-Tecimen.Airdrop-Progress-Animation", + teamIdentifier: "R2MF39ASQT", + displayVersion: "1.0", + bundleVersion: "1", + appIcon: .placeholder(icon: .tv), + accentColor: .presetColor(.red), + supportedDeviceFamilies: [ + .pad, + .phone + ], + supportedInterfaceOrientations: [ + .portrait, + .landscapeRight, + .landscapeLeft, + .portraitUpsideDown(.when(deviceFamilies: [.pad])) + ] + ) + ], + targets: [ + .executableTarget( + name: "AppModule", + path: "." + ) + ] +) \ No newline at end of file diff --git a/Airdrop Progress Animation.swiftpm/PersonBadgeView + ViewModel.swift b/Airdrop Progress Animation.swiftpm/PersonBadgeView + ViewModel.swift new file mode 100644 index 0000000..f24202b --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/PersonBadgeView + ViewModel.swift @@ -0,0 +1,60 @@ +// +// PersonBadgeView + ViewModel.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 5.08.2022. +// + +import Foundation + + +struct User: Identifiable{ + let id: UUID + let name: String + let device: String = Device.allCases.randomElement()?.rawValue ?? "" +} + +extension PersonBadgeView{ + class ViewModel: ObservableObject{ + @Published var state: States = .idle + let waitDelay: Double + let sendingDelay: Double + var user: User! + + + init(waitDelay: Double, sendingDelay: Double) { + self.waitDelay = waitDelay + self.sendingDelay = sendingDelay + } + + func requestTransfer(){ + self.state = .waiting + print("Waiting") + DispatchQueue.main.asyncAfter(deadline: .now() + waitDelay){ [unowned self] in + transfer() + } + } + + func transfer(){ + self.state = .sending + print("Sending") + + // Waits till the animation is compeleted. + DispatchQueue.main.asyncAfter(deadline: .now() + ((sendingDelay * 2))){ [unowned self] in + self.setSent() + } + } + + func setSent(){ + self.state = .sent + print("Sent") + } + + func resetTransfer(){ + self.state = .idle + } + + + + } +} diff --git a/Airdrop Progress Animation.swiftpm/PersonBadgeView.swift b/Airdrop Progress Animation.swiftpm/PersonBadgeView.swift new file mode 100644 index 0000000..32f1fd2 --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/PersonBadgeView.swift @@ -0,0 +1,148 @@ +// +// PersonBadgeView.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 4.08.2022. +// + +import SwiftUI + +// There are no optimal solutions (as far as I know) to stopping "repeatForever" animations, so In couple of WWDC sessions demonstraids that recommended way of doings is switching between animated and non-animated versions of a view. Which makes me a bit uncomfortable, a stop method for animations would be appreciated. +fileprivate struct BlinkingStatusText: View{ + + @Binding var text: String + @State var textColor: Color + + var body: some View{ + Text(text) + .foregroundColor(textColor) + .onAppear{ + withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: true)){ + textColor = .clear + } + } + } +} + +struct ProgressRing: View{ + + @Binding var progressAmount: CGFloat + + var body: some View{ + GeometryReader{ geometry in + ZStack{ + Circle() + .stroke( + Color.progressRingBackgroundColor, + lineWidth: geometry.size.width * 1/20 + ) + Circle() + .trim(from: 0, to: progressAmount / 100) + .stroke( + Color.progressRingAccentColor, + lineWidth: geometry.size.width * 1/20 + ) + .rotationEffect(.degrees(-90)) + } + } + } +} + +// Data transfer on AirDrop contains 4 states: idle, waiting, sending and sent. View Model in PersonBadge view contains a state variable, when airdrop is requested those states are iterated via given delay values. +struct PersonBadgeView: View { + + @State var progress: CGFloat = 0 + let user: User + // Random sending delay and wait delay for users to add variaty. + @StateObject private var viewModel: ViewModel = .init(waitDelay: Double.random(in: 2...5), sendingDelay: Double.random(in: 1...5)) + + @State private var deviceTextColor: Color = .textColor + @State private var statusTextColor: Color = .textColor + @State private var statusText: String = "" + + init(user: User){ + self.user = user + } + + var body: some View { + GeometryReader{ geometry in + VStack { + ZStack{ + ZStack{ + Circle() + .foregroundColor(.badgeBackgroundColor) + .padding([.all], geometry.size.width / 25) + // User Icon and animations are contained in UserIconView. + UserIconView(state: $viewModel.state, size: .init(width: geometry.size.width / 1.5, height: geometry.size.height / 1.5)) + .frame(maxWidth: geometry.size.width * 3/4, maxHeight: geometry.size.height * 3/4) + } + // ProgressRing is wraps around UserIconView and displays a progress animation that completes in given sending delay time. + ProgressRing(progressAmount: $progress) + } + .frame(width: geometry.size.width, height: geometry.size.height) + .aspectRatio(1.0, contentMode: .fit) + .onReceive(viewModel.$state){ state in + switch state{ + case .idle: + resetAnimation() + statusText = "from \(user.name)" + statusTextColor = .textColor + case .waiting: + statusText = "Waiting..." + statusTextColor = .badgeBackgroundColor + case .sending: + startAnimation() + statusText = "Sending..." + case .sent: + statusText = "Sent" + statusTextColor = .progressRingAccentColor + } + } + .onAppear{ + viewModel.user = user + } + .onTapGesture{ + if viewModel.state == .idle{ + viewModel.requestTransfer() + } + } + .onLongPressGesture{ + viewModel.resetTransfer() + } + Text("\(user.device)") + .foregroundColor(deviceTextColor) + // Switching between animated and non-animated versions of text view. + if viewModel.state == .waiting{ + BlinkingStatusText(text: $statusText, textColor: statusTextColor) + } else { + Text(statusText) + .foregroundColor(statusTextColor) + } + + } + } + } + + private func startAnimation(){ + // Ease Ins to first half of the progress animation. + withAnimation(.easeIn(duration: viewModel.sendingDelay)){ + progress = 50 + } + // Ease Outs to second half of the progress animaiton. + withAnimation(.easeOut(duration: viewModel.sendingDelay).delay(viewModel.sendingDelay)){ + progress = 100 + } + } + + private func resetAnimation(){ + // Resets progress animation. + progress = 0 + } + +} + +struct PersonBadgeView_Previews: PreviewProvider { + static var previews: some View { + PersonBadgeView(user: .init(id: UUID(), name: "Tony")) + } +} diff --git a/Airdrop Progress Animation.swiftpm/UserIconView.swift b/Airdrop Progress Animation.swiftpm/UserIconView.swift new file mode 100644 index 0000000..1cd81fc --- /dev/null +++ b/Airdrop Progress Animation.swiftpm/UserIconView.swift @@ -0,0 +1,167 @@ +// +// UserIconView.swift +// Airdrop Progress Animation +// +// Created by Mert Tecimen on 6.08.2022. +// + +import SwiftUI +import Combine + +struct Arc: Shape, Animatable { + var startAngle: Angle + var endAngle: Angle + var clockwise: Bool + var animatableData: Double + + func path(in rect: CGRect) -> Path { + var path = Path() + // Store of arc is starts from 90 degree (which is bottom center of the circle) animatableData (which 30 degree in this case) added for start angle and substracted fro end angle, at the end start angle 120 and end angle at 60 degress thus represents a smile. + path.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: rect.width / 2, startAngle: .degrees(90+animatableData), endAngle: .degrees(90-animatableData), clockwise: clockwise) + + return path + } +} + +fileprivate struct AnimatedEyeView: View{ + + @State var primaryEyeColor: Color + let switchColor: Color + let width: CGFloat + // To achive blinking, eye color switched between background color and badge background color with spring animation. + var body: some View{ + HStack(spacing: width / 4){ + Circle() + .foregroundColor(primaryEyeColor) + Circle() + .foregroundColor(primaryEyeColor) + } + .onAppear{ + withAnimation(.spring().repeatForever(autoreverses: true)){ + primaryEyeColor = switchColor + } + } + } +} + +fileprivate struct EyeView: View{ + + @Binding var eyeColor: Color + let width: CGFloat + + var body: some View{ + HStack(spacing: width / 4){ + Circle() + .foregroundColor(eyeColor) + Circle() + .foregroundColor(eyeColor) + } + } +} + +struct UserIconHeadView: View{ + @Binding var state: States + @State private var degree: Double = 0 + @State private var eyeColor: Color = .backgroundColor + @State private var animation: Animation = .spring().repeatForever(autoreverses: true) + + var body: some View{ + GeometryReader{ geometry in + ZStack{ + Circle() + .foregroundColor(.backgroundColor) + ZStack { + if state == .waiting || state == .sending { + AnimatedEyeView(primaryEyeColor: .backgroundColor, switchColor: .badgeBackgroundColor, width: geometry.size.width / 2) + .frame(maxWidth: geometry.size.width / 2, minHeight: geometry.size.height / 2) + .offset(y: geometry.size.height / 5) + } else { + EyeView(eyeColor: $eyeColor, width: geometry.size.width / 2) + .frame(maxWidth: geometry.size.width / 2, minHeight: geometry.size.height / 2) + .offset(y: geometry.size.height / 5) + } + // Smile is achived with Arc View. See the definition for details. + Arc(startAngle: .degrees(100), endAngle: .degrees(30), clockwise: true, animatableData: degree) + .stroke(Color.badgeBackgroundColor, lineWidth: geometry.size.height / 20) + } + .offset(y: -1*geometry.size.height / 5) + } + .onReceive(Just(self.$state)){ _ in + switch state { + case .idle: + // EyeColor and degree reseted to default value. + eyeColor = .backgroundColor + degree = 0 + case .waiting: + degree = 0 + case .sending: + break + case .sent: + eyeColor = .badgeBackgroundColor + // Sets degree for smile. + withAnimation(.linear(duration: 0.5)){ + degree = 30 + } + } + } + } + } +} + +struct UserIconView: View { + @Binding var state: States + let size: CGSize + + // To achive reuseablity I have used coefficients on animation and size values to make views and animationt adeptive to diffrent sizes. + @State private var headOffsetCoefficient: Double = 1/6 + @State private var bodyOffsetCoefficient: Double = 2/3 + @State private var headScaleCoefficient: Double = 1 + + var body: some View { + ZStack{ + ZStack{ + UserIconHeadView(state: $state) + .frame(maxWidth: size.width / 2, maxHeight: size.height / 2) + .offset(y: -size.height * headOffsetCoefficient) + .scaleEffect(x: headScaleCoefficient, y: headScaleCoefficient, anchor: .center) + Circle() + .foregroundColor(.backgroundColor) + .offset(y: size.height * bodyOffsetCoefficient) + .scaleEffect(x: headScaleCoefficient, y: headScaleCoefficient, anchor: .center) + } + } + .frame(width: size.width, height: size.height) + .clipShape(Circle()) + // Little Combine things(!) + .onReceive(Just($state)){ _ in + switch state { + case .idle: + // Coefficients resetted to default value on idle. + headOffsetCoefficient = 1/6 + bodyOffsetCoefficient = 2/3 + headScaleCoefficient = 1.0 + case .waiting: + break + case .sending: + withAnimation(.linear(duration: 0.5)){ + // Torse of the user illustration is offsetted out of view. + bodyOffsetCoefficient = 1 + // Head of the user illustration centered on the view. + headOffsetCoefficient = 0 + // Head of the user illustration scaled to twice in size. + headScaleCoefficient = 2 + + } + case .sent: + break + } + } + } +} + +struct UserIconView_Previews: PreviewProvider { + static var previews: some View { + UserIconView(state: .constant(.idle), size: .init(width: 300, height: 300)) + UserIconHeadView(state: .constant(.idle)) + } +}