├── .gitignore ├── previews ├── play_button.mov └── highlighted_search.jpg ├── waveform_button ├── AudioPreviewModel.swift ├── WaveformService.swift ├── AudioPlayViewModel.swift └── AudioVisualization.swift ├── LICENSE ├── highlighted_search.swift ├── README.md ├── playful_button.swift ├── notification_animation.swift └── custom_refresh_view.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /previews/play_button.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiangruss/playful-swiftui-examples/HEAD/previews/play_button.mov -------------------------------------------------------------------------------- /previews/highlighted_search.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabiangruss/playful-swiftui-examples/HEAD/previews/highlighted_search.jpg -------------------------------------------------------------------------------- /waveform_button/AudioPreviewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPreview.swift 3 | // whatsgoingon 4 | // 5 | // Created by Fabian Gruß on 06.10.23. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct AudioPreviewModel: Hashable { 12 | var magnitude: Float 13 | var color: Color 14 | var hasPlayed: Bool = false 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Fabian Gruß 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /highlighted_search.swift: -------------------------------------------------------------------------------- 1 | // Parent view with the search functionality. 2 | // Search here is based on a Person object with first- and possibly lastName 3 | struct PersonAddingSheet: View { 4 | 5 | @State private var searchText: String = "" 6 | // ... 7 | var body: some View { 8 | // ... 9 | // Calculate search results (not case sensitive) 10 | var searchResults: [Person] { 11 | if searchText.isEmpty { 12 | return persons 13 | } else { 14 | return persons.filter { 15 | var name = "\($0.firstName)" 16 | if $0.lastName != nil { 17 | name += " \($0.lastName!)" 18 | } 19 | return name.lowercased().contains(searchText.lowercased()) 20 | } 21 | } 22 | } 23 | // ... 24 | // Hand over current searchText to element 25 | ForEach(searchResults, id: \.id) { singlePerson in 26 | PersonListElement(person: singlePerson, numberOfUpdates: n, searchText: searchText) 27 | } 28 | } 29 | } 30 | 31 | 32 | // Child view that displays search highlighting 33 | struct PersonListElement: View { 34 | var person: Person 35 | var numberOfUpdates: Int 36 | var searchText: String 37 | 38 | // This function builds the highlighted text 39 | func highlightedText(for string: String) -> some View { 40 | guard !searchText.isEmpty, let range = string.range(of: searchText, options: .caseInsensitive) else { 41 | // Default text 42 | return AnyView(Text(string).tinyCardHeader(fontWeight: .medium)) 43 | } 44 | 45 | let prefix = String(string[string.startIndex.. ()) 16 | } 17 | 18 | // MARK: - Service Implementation 19 | 20 | class Service { 21 | // Singleton instance for the service class 22 | static let shared: ServiceProtocol = Service() 23 | 24 | // Private initializer to ensure only one instance is created 25 | private init() {} 26 | } 27 | 28 | extension Service: ServiceProtocol { 29 | /// Creates a waveform buffer from the provided audio data 30 | /// - Parameters: 31 | /// - data: The audio data 32 | /// - id: The identifier for the audio 33 | /// - samplesCount: The number of samples to create for the waveform 34 | /// - completion: Closure to return the generated waveform samples 35 | func buffer(data: Data, id: String, samplesCount: Int, completion: @escaping ([AudioPreviewModel]) -> ()) { 36 | DispatchQueue.global(qos: .userInteractive).async { 37 | do { 38 | // Convert audio data to a temporary file 39 | let directory = FileManager.default.temporaryDirectory 40 | let path = directory.appendingPathComponent("\(id).wav") 41 | try data.write(to: path) 42 | 43 | // Read the audio file 44 | let file = try AVAudioFile(forReading: path) 45 | 46 | // Create buffer from the audio file 47 | if let format = AVAudioFormat(commonFormat: .pcmFormatFloat32, 48 | sampleRate: file.fileFormat.sampleRate, 49 | channels: file.fileFormat.channelCount, interleaved: false), 50 | let buf = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) 51 | { 52 | try file.read(into: buf) 53 | 54 | guard let floatChannelData = buf.floatChannelData else { return } 55 | let frameLength = Int(buf.frameLength) 56 | let samples = Array(UnsafeBufferPointer(start: floatChannelData[0], count: frameLength)) 57 | 58 | // Convert raw samples to AudioPreviewModel format 59 | var result = [AudioPreviewModel]() 60 | let chunked = samples.chunked(into: samples.count / samplesCount) 61 | for row in chunked { 62 | var accumulator: Float = 0 63 | let newRow = row.map { $0 * $0 } 64 | accumulator = newRow.reduce(0, +) 65 | let power: Float = accumulator / Float(row.count) 66 | let decibles = 10 * log10f(power) 67 | 68 | result.append(AudioPreviewModel(magnitude: decibles, color: .pinkSecondary, hasPlayed: false)) 69 | } 70 | 71 | // Return the processed samples on the main thread 72 | DispatchQueue.main.async { 73 | completion(result) 74 | } 75 | } 76 | } catch { 77 | print("Audio Error: \(error)") 78 | } 79 | } 80 | } 81 | } 82 | 83 | // MARK: - Array Extension 84 | 85 | extension Array { 86 | /// Chunks the array into smaller arrays of a specified size 87 | /// - Parameter size: The size of each chunk 88 | /// - Returns: A 2D array where each inner array is of the given size or smaller 89 | func chunked(into size: Int) -> [[Element]] { 90 | return stride(from: 0, to: count, by: size).map { 91 | Array(self[$0 ..< Swift.min($0 + size, count)]) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /playful_button.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPlayerButton.swift 3 | // whatsgoingon 4 | // 5 | // Created by Fabian Gruß on 06.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AudioPlayerButton: View { 11 | // Default state 12 | @State private var state: AudioPlayerState = .isIdle 13 | 14 | // Vars for the timer 15 | @State private var recordingStart: Date = .now 16 | // For the example, let's go with 5 seconds duration. Use your own values from the actual audio files here 17 | private var duration = 5.0 18 | @State private var elapsedPlaybackTime = TimeInterval() 19 | @State private var totalPausedTime = TimeInterval() 20 | @State private var progress: CGFloat = 0 21 | 22 | // Publish a timer to listen to for the counter 23 | @State var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 24 | 25 | // Colors (adjust to your liking 26 | private var primaryColor = Color(hex: 0xff58a8) // Pink primary 27 | private var secondaryColor = Color(hex: 0xfcdbeb) // Pink secondary 28 | 29 | var body: some View { 30 | Button { 31 | // A little extra bounce for actions through the stateMachine 32 | withAnimation(.bouncy(extraBounce: 0.2)) { 33 | stateMachine() 34 | } 35 | } label: { 36 | HStack { 37 | Image(systemName: state == .isPlaying ? "pause.fill" : "play.fill").font(.system(size: 22)) 38 | .contentTransition(.symbolEffect(.replace)) 39 | .foregroundColor(primaryColor) 40 | .padding(.leading, 12) 41 | Spacer() 42 | HStack { 43 | Spacer() 44 | Text(formatTimeInterval(interval: elapsedPlaybackTime)) 45 | .font(.caption) 46 | .fontWeight(.semibold) 47 | .fontDesign(.rounded) 48 | .foregroundStyle(primaryColor) 49 | .contentTransition(.opacity) 50 | .padding(.trailing, 12) 51 | .frame(width: 52, alignment: .leading) 52 | } 53 | 54 | .onReceive(timer) { firedDate in 55 | // Only update UI if isPlaying 56 | if state == .isPlaying { 57 | let elapsedTime = firedDate.timeIntervalSince(recordingStart) 58 | withAnimation { 59 | progress = CGFloat(elapsedTime / 5.0) 60 | } 61 | elapsedPlaybackTime = TimeInterval(progress * 5) 62 | 63 | // Stop the playing with a little less bounce 64 | if elapsedTime > duration { 65 | withAnimation(.bouncy(extraBounce: 0.1)) { 66 | state = .isIdle 67 | reset() 68 | timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 69 | } 70 | } 71 | } 72 | } 73 | } 74 | .frame(width: state == .isIdle ? 96 : 196, height: 44) 75 | .background(secondaryColor) 76 | .clipShape(RoundedRectangle(cornerRadius: 12.0)) 77 | } 78 | .buttonStyle(SquishyButton(color: secondaryColor, goalColor: primaryColor.opacity(0.3), minWidth: 96, minHeight: 44, cornerRadius: 12)) 79 | .padding(.all, 48) 80 | } 81 | 82 | private func stateMachine() { 83 | switch state { 84 | case .isIdle: 85 | reset() 86 | state = .isPlaying 87 | case .isPlaying: 88 | totalPausedTime += Date().timeIntervalSince(recordingStart) 89 | timer.upstream.connect().cancel() 90 | state = .isPaused 91 | case .isPaused: 92 | recordingStart = Date().addingTimeInterval(-totalPausedTime) 93 | timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() 94 | state = .isPlaying 95 | } 96 | } 97 | 98 | /// Formats a time interval to mm:ss 99 | private func formatTimeInterval(interval: TimeInterval) -> String { 100 | let duration: Duration = .seconds(interval) 101 | return duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) // "02:06" 102 | } 103 | 104 | /// Reset timer values 105 | private func reset() { 106 | recordingStart = Date() 107 | totalPausedTime = TimeInterval() 108 | elapsedPlaybackTime = TimeInterval() 109 | } 110 | } 111 | 112 | enum AudioPlayerState: String { 113 | case isIdle, isPlaying, isPaused 114 | } 115 | 116 | #Preview { 117 | AudioPlayerButton() 118 | } 119 | 120 | // Let's use hex colors 121 | extension Color { 122 | init(hex: UInt, alpha: Double = 1) { 123 | self.init( 124 | .sRGB, 125 | red: Double((hex >> 16) & 0xff) / 255, 126 | green: Double((hex >> 08) & 0xff) / 255, 127 | blue: Double((hex >> 00) & 0xff) / 255, 128 | opacity: alpha 129 | ) 130 | } 131 | } 132 | 133 | // Button style 134 | struct SquishyButton: ButtonStyle { 135 | var color: Color 136 | var goalColor: Color 137 | var minWidth: Double = 98 138 | var minHeight: Double = 70 139 | var cornerRadius: Double = 18 140 | 141 | func makeBody(configuration: Configuration) -> some View { 142 | configuration.label 143 | .frame(minWidth: minWidth, minHeight: minHeight) 144 | .overlay { 145 | Squircle(cornerRadius: configuration.isPressed ? (cornerRadius+1) : cornerRadius).fill(configuration.isPressed ? goalColor.opacity(0.4) : .clear) 146 | } 147 | .background(Squircle(cornerRadius: configuration.isPressed ? (cornerRadius+1) : cornerRadius)) 148 | .foregroundStyle(color) 149 | .scaleEffect(configuration.isPressed ? 0.94 : 1.0) 150 | .animation(.spring(duration: 0.5), value: configuration.isPressed) 151 | .sensoryFeedback(.impact(intensity: 0.8), trigger: configuration.isPressed) 152 | } 153 | } 154 | 155 | // Shape style 156 | struct Squircle: Shape { 157 | var cornerRadius: CGFloat 158 | 159 | func path(in rect: CGRect) -> Path { 160 | let path = UIBezierPath( 161 | roundedRect: rect, 162 | byRoundingCorners: .allCorners, 163 | cornerRadii: CGSize(width: cornerRadius, height: cornerRadius) 164 | ) 165 | return Path(path.cgPath) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /waveform_button/AudioPlayViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioVisualization.swift 3 | // whatsgoingon 4 | // 5 | // Created by Fabian Gruß on 06.10.23. 6 | // 7 | 8 | import AVFoundation 9 | import AVKit 10 | import Combine 11 | import Foundation 12 | import SwiftUI 13 | 14 | class AudioPlayViewModel: NSObject, ObservableObject, AVAudioPlayerDelegate { 15 | // MARK: - Published Properties 16 | 17 | @Published var isPlaying: Bool = false 18 | @Published public var soundSamples = [AudioPreviewModel]() 19 | @Published var displayedTime: TimeInterval = 0.0 20 | @Published var player: AVAudioPlayer! 21 | @Published var session: AVAudioSession! 22 | 23 | // MARK: - Private Properties 24 | 25 | private var timer: Timer? 26 | private var time_interval: TimeInterval = 0.0 27 | private let data: Data 28 | private let id: String 29 | private var index = 0 30 | private var dataManager: ServiceProtocol 31 | private let sample_count: Int 32 | 33 | // MARK: - Initialization 34 | 35 | init(data: Data, id: String, sample_count: Int, dataManager: ServiceProtocol = Service.shared) { 36 | self.data = data 37 | self.id = id 38 | self.sample_count = sample_count 39 | self.dataManager = dataManager 40 | super.init() 41 | 42 | setupAudioSession() 43 | initializePlayer() 44 | visualizeAudio() 45 | } 46 | 47 | // MARK: - Audio Setup 48 | 49 | private func setupAudioSession() { 50 | do { 51 | session = AVAudioSession.sharedInstance() 52 | try session.setCategory(.playAndRecord) 53 | try session.overrideOutputAudioPort(AVAudioSession.PortOverride.speaker) 54 | } catch { 55 | print(error.localizedDescription) 56 | } 57 | } 58 | 59 | private func initializePlayer() { 60 | do { 61 | player = try AVAudioPlayer(data: data) 62 | player.delegate = self 63 | displayedTime = player.duration 64 | } catch { 65 | print(error.localizedDescription) 66 | } 67 | } 68 | 69 | // MARK: - Timer Functions 70 | 71 | /// Starts a timer to update the displayed time and sound samples visualization. 72 | func startTimer() { 73 | count_duration { duration in 74 | // Set interval duration and adjust it slightly 75 | self.time_interval = (duration / Double(self.sample_count)) - 0.03 76 | 77 | // Schedule a repeating timer based on the time interval 78 | self.timer = Timer.scheduledTimer(withTimeInterval: self.time_interval, repeats: true, block: { _ in 79 | self.displayedTime = self.player.currentTime 80 | 81 | if self.index < self.soundSamples.count { 82 | self.soundSamples[self.index].color = Color.pinkPrimary 83 | withAnimation(Animation.linear(duration: self.time_interval)) { 84 | self.soundSamples[self.index].hasPlayed = true 85 | } 86 | } 87 | 88 | self.index += 1 89 | }) 90 | } 91 | } 92 | 93 | // MARK: - Audio Controls 94 | 95 | /// Plays the audio, initializes a timer and updates the displayed time. 96 | func playAudio() { 97 | if isPlaying { 98 | pauseAudio() 99 | } else { 100 | displayedTime = 0.0 101 | // Reset player if it finished playing 102 | if player.currentTime >= player.duration { 103 | player.currentTime = 0.0 104 | } 105 | 106 | isPlaying.toggle() 107 | player.play() 108 | startTimer() 109 | count_duration { _ in } 110 | } 111 | } 112 | 113 | /// Pauses the audio and invalidates the timer. 114 | func pauseAudio() { 115 | player.pause() 116 | timer?.invalidate() 117 | isPlaying = false 118 | } 119 | 120 | /// Called when the audio finishes playing. 121 | /// Resets the sound samples and updates the displayed time. 122 | func playerDidFinishPlaying() { 123 | displayedTime = player.duration 124 | 125 | // Ensure the last sound sample is colored 126 | if soundSamples.last?.hasPlayed == false { 127 | soundSamples[index].color = Color.pinkPrimary 128 | withAnimation(Animation.linear(duration: time_interval)) { 129 | self.soundSamples[self.index].hasPlayed = true 130 | } 131 | } 132 | 133 | print("Has finished playing.") 134 | player.pause() 135 | timer?.invalidate() 136 | player.stop() 137 | 138 | // Reset properties after a short delay 139 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { 140 | withAnimation(.bouncy(extraBounce: 0.1)) { 141 | self.isPlaying = false 142 | self.time_interval = 0 143 | self.index = 0 144 | self.soundSamples = self.soundSamples.map { tmp -> AudioPreviewModel in 145 | var cur = tmp 146 | cur.color = .pinkSecondary 147 | cur.hasPlayed = false 148 | return cur 149 | } 150 | } 151 | } 152 | } 153 | 154 | // MARK: - Audio Visualization 155 | 156 | /// Uses the dataManager to process the audio data and generate sound samples for visualization. 157 | func visualizeAudio() { 158 | dataManager.buffer(data: data, id: id, samplesCount: sample_count) { results in 159 | self.soundSamples = results 160 | } 161 | } 162 | 163 | // MARK: - Cleanup 164 | 165 | /// Removes the audio file from temporary storage and posts a notification to hide the audio preview. 166 | func removeAudio() { 167 | do { 168 | let directory = FileManager.default.temporaryDirectory 169 | let path = directory.appendingPathComponent("\(id).wav") 170 | try FileManager.default.removeItem(at: path) 171 | NotificationCenter.default.post(name: Notification.Name("hide_audio_preview"), object: nil) 172 | } catch { 173 | print(error) 174 | } 175 | } 176 | 177 | // MARK: - Utility Functions 178 | 179 | /// Fetches the duration of the audio. 180 | private func count_duration(completion: @escaping (Float64) -> ()) { 181 | completion(Float64(player.duration)) 182 | } 183 | 184 | // MARK: - AVAudioPlayerDelegate 185 | 186 | /// Delegate method that's called when the audio player finishes playing the audio. 187 | func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 188 | playerDidFinishPlaying() 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /waveform_button/AudioVisualization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioVisualization.swift 3 | // whatsgoingon 4 | // 5 | // Created by Fabian Gruß on 08.10.23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // MARK: - Audio Visualization Main View 11 | 12 | struct AudioVisualization: View { 13 | @StateObject private var audioVM: AudioPlayViewModel 14 | @State var overlayOpacity: Double = 0.0 15 | 16 | // MARK: - Initializer 17 | 18 | init(data: Data, id: String) { 19 | _audioVM = StateObject(wrappedValue: AudioPlayViewModel(data: data, id: id, sample_count: 20)) 20 | } 21 | 22 | // MARK: - Body 23 | 24 | var body: some View { 25 | if !(audioVM.isPlaying || (!audioVM.isPlaying && audioVM.player.currentTime != 0.0)) { 26 | Spacer() 27 | } 28 | 29 | // Play/Pause button 30 | Button(action: handlePlayPauseAction) { 31 | playerControlContent 32 | } 33 | .onChange(of: audioVM.isPlaying || (!audioVM.isPlaying && audioVM.player.currentTime != 0.0)) { _, new in 34 | handleOverlayOpacityChange(new) 35 | } 36 | .buttonStyle(SquishySizelessButton(color: .pinkSecondary, goalColor: .pinkPrimary.opacity(0.3), cornerRadius: 14)) 37 | } 38 | 39 | // MARK: - Private Helpers 40 | 41 | /// Handles the play/pause action. 42 | private func handlePlayPauseAction() { 43 | if audioVM.isPlaying { 44 | // A little extra bounce for actions through the stateMachine 45 | withAnimation(.bouncy(extraBounce: 0.1)) { 46 | audioVM.pauseAudio() 47 | } 48 | } else { 49 | // A little extra bounce for actions through the stateMachine 50 | withAnimation(.bouncy(extraBounce: 0.1)) { 51 | audioVM.playAudio() 52 | } 53 | } 54 | } 55 | 56 | /// Modifies the overlay opacity based on the audio's play status. 57 | private func handleOverlayOpacityChange(_ isPlaying: Bool) { 58 | if isPlaying { 59 | // Introduce a delay before starting the fade-in animation 60 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { 61 | withAnimation(.easeOut(duration: 1.0)) { 62 | overlayOpacity = 1.0 63 | } 64 | } 65 | } else { 66 | withAnimation(.easeOut(duration: 0.2)) { 67 | overlayOpacity = 0.0 68 | } 69 | } 70 | } 71 | 72 | /// Content of the player's control button. 73 | private var playerControlContent: some View { 74 | ZStack(alignment: .center) { 75 | HStack { 76 | CustomIcon(type: audioVM.isPlaying ? .pause : .play, originalSize: 24.0) 77 | .contentTransition(.symbolEffect(.replace)) 78 | .foregroundColor(.pinkPrimary) 79 | .padding(.leading, 14) 80 | 81 | if audioVM.isPlaying || (!audioVM.isPlaying && audioVM.player.currentTime != 0.0) { 82 | Spacer() 83 | } 84 | 85 | ZStack(alignment: .leading) { 86 | Text("00:00").opacity(0.0).tinyCardHeader(color: .pinkPrimary) 87 | Text(formatTimeInterval(interval: audioVM.displayedTime)).tinyCardHeader(color: .pinkPrimary) 88 | } 89 | .frame(width: 48) 90 | .padding(.trailing, 16) 91 | } 92 | 93 | HStack(alignment: .center, spacing: 3) { 94 | ForEach(audioVM.soundSamples, id: \.self) { model in 95 | BarView(value: normalizeSoundLevel(level: model.magnitude), color: model.hasPlayed ? .pinkPrimary : .pinkPrimary.opacity(0.3)) 96 | } 97 | } 98 | .clipped() 99 | .offset(x: -16) 100 | .opacity(overlayOpacity) 101 | } 102 | .background(.pinkSecondary) 103 | .clipShape(RoundedRectangle(cornerRadius: 14.0)) 104 | } 105 | 106 | /// Normalizes the sound level for display. 107 | private func normalizeSoundLevel(level: Float) -> CGFloat { 108 | let level = max(0.2, CGFloat(level) + 70) / 2 // between 0.1 and 35 109 | return CGFloat(level * (40 / 35)) 110 | } 111 | 112 | /// Formats a time interval to mm:ss 113 | private func formatTimeInterval(interval: TimeInterval) -> String { 114 | let duration: Duration = .seconds(interval) 115 | return duration.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2))) // "02:06" 116 | } 117 | } 118 | 119 | // MARK: - BarView Component 120 | 121 | struct BarView: View { 122 | let value: CGFloat 123 | var color: Color = .pinkSecondary 124 | 125 | var body: some View { 126 | Rectangle() 127 | .fill(color) 128 | .cornerRadius(10) 129 | .frame(width: 2, height: value) 130 | .animation(Animation.easeInOut(duration: 1.0), value: color) 131 | } 132 | } 133 | 134 | #Preview { 135 | VStack(alignment: .leading, spacing: 0) { 136 | @State var isPlaying = true 137 | @State var overlayOpacity: Double = 0.0 138 | 139 | Spacer() 140 | 141 | // Play/Pause button 142 | Button {} label: { 143 | HStack { 144 | CustomIcon(type: isPlaying ? .pause : .play, originalSize: 24.0) 145 | .contentTransition(.symbolEffect(.replace)) 146 | .foregroundColor(.pinkPrimary) 147 | .padding(.leading, 14) 148 | 149 | if isPlaying { Spacer() } 150 | 151 | ZStack(alignment: .trailing) { 152 | Text("00:00").opacity(0.0).tinyCardHeader(color: .pinkPrimary) 153 | Text("00:05").tinyCardHeader(color: .pinkPrimary) 154 | }.frame(width: 48) 155 | .padding(.trailing, 16) 156 | } 157 | .background(.pinkSecondary) 158 | .clipShape(RoundedRectangle(cornerRadius: 14.0)) 159 | 160 | .onChange(of: isPlaying) { 161 | if isPlaying { 162 | // Introduce a delay before starting the fade-in animation 163 | DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { 164 | withAnimation { 165 | overlayOpacity = 1.0 166 | } 167 | } 168 | } else { 169 | withAnimation { 170 | overlayOpacity = 0.0 171 | } 172 | } 173 | } 174 | .overlay(alignment: .center) { 175 | HStack(alignment: .center, spacing: 3) { 176 | ForEach(0 ... 25, id: \.self) { _ in 177 | BarView(value: Double.random(in: 23 ... 35), color: .pinkPrimary) 178 | } 179 | } 180 | .clipped() 181 | .offset(x: -16) 182 | .opacity(overlayOpacity) 183 | } 184 | } 185 | 186 | .buttonStyle(SquishySizelessButton(color: .pinkSecondary, goalColor: .pinkPrimary.opacity(0.3), cornerRadius: 14)) 187 | } 188 | .frame(width: 280, height: 150.0) 189 | .padding(.all, 16) 190 | .background(.buttonGrey) 191 | .cornerRadius(20.0) 192 | } 193 | -------------------------------------------------------------------------------- /notification_animation.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // This view simulates a priming screen to show the user how notifications would look and prompt for notification permissions. 4 | struct NotificationAnimation: View { 5 | // State to handle animation. 6 | @State private var shouldAnimate = false 7 | 8 | // Router to handle navigation. 9 | @EnvironmentObject var router: Router 10 | 11 | var body: some View { 12 | VStack { 13 | // Background for iPhone visual representation. 14 | self.iPhoneBG() 15 | .padding(.top, 64) 16 | 17 | // Simulated notification stack. Opacity and offset (position) are altered by the animation 18 | ZStack { 19 | // Lower notification 20 | self.notificationView(avatarPath: "memoji-3", title: "Check in with Johnny", subtitle: "Regular reminder", time: "4d") 21 | .offset(CGSize(width: 0, height: self.shouldAnimate ? -30 : 100)) 22 | .scaleEffect(0.8) 23 | .opacity(self.shouldAnimate ? 0.8 : 0) 24 | 25 | // Middle notification 26 | self.notificationView(avatarPath: "memoji-6", title: "It’s William’s birthday soon!", subtitle: "One week left for a present", time: "4h") 27 | .offset(CGSize(width: 0, height: self.shouldAnimate ? -80 : 100)) 28 | .scaleEffect(0.9) 29 | .opacity(self.shouldAnimate ? 0.9 : 0) 30 | 31 | // Top notification 32 | self.notificationView(avatarPath: "memoji-1", title: "Check in with Melissa", subtitle: "Regular reminder", time: "now") 33 | .offset(CGSize(width: 0, height: self.shouldAnimate ? -130 : 100)) 34 | .opacity(self.shouldAnimate ? 1 : 0) 35 | } 36 | 37 | // Add a slight gradient on top to focus on the top notification more 38 | .overlay { 39 | Rectangle() 40 | .foregroundColor(.clear) 41 | .frame(width: 392, height: 360) 42 | .background( 43 | LinearGradient( 44 | stops: [ 45 | Gradient.Stop(color: .white.opacity(0), location: 0.00), 46 | Gradient.Stop(color: .white.opacity(0), location: 0.00), 47 | Gradient.Stop(color: .white.opacity(1), location: 1.00), 48 | ], 49 | startPoint: UnitPoint(x: 0.5, y: 0.13), 50 | endPoint: UnitPoint(x: 0.5, y: 0.83) 51 | ) 52 | ) 53 | } 54 | 55 | Spacer() 56 | } 57 | 58 | // The magic happens here. Set the animation bool to true and do that with an animation 59 | .onAppear(perform: { 60 | withAnimation(Animation.bouncy(duration: 0.7).delay(0.5)) { 61 | self.shouldAnimate = true 62 | } 63 | }) 64 | } 65 | 66 | // iPhone Background (Rectangle + Time + Gradient) 67 | @ViewBuilder 68 | private func iPhoneBG() -> some View { 69 | ZStack { 70 | Rectangle() 71 | .foregroundStyle(.clear) 72 | .overlay( 73 | RoundedRectangle(cornerRadius: 60) 74 | .stroke(Color(red: 0.87, green: 0.89, blue: 1), lineWidth: 6) 75 | ) 76 | .frame(height: 280) 77 | .padding(.horizontal, 48) 78 | 79 | Rectangle() 80 | .foregroundColor(.white.opacity(0.4)) 81 | .frame(width: 392, height: 320) 82 | .background( 83 | LinearGradient( 84 | stops: [ 85 | Gradient.Stop(color: .white.opacity(0), location: 0.00), 86 | Gradient.Stop(color: .white.opacity(0), location: 0.00), 87 | Gradient.Stop(color: .white.opacity(1), location: 1.00), 88 | ], 89 | startPoint: UnitPoint(x: 0.5, y: 0.13), 90 | endPoint: UnitPoint(x: 0.5, y: 0.83) 91 | ) 92 | ) 93 | 94 | VStack { 95 | Text(formatCurrentDate()) 96 | .font(.system(size: 16)) 97 | .fontWeight(.medium) 98 | .multilineTextAlignment(.center) 99 | .foregroundColor(.accentColorSecondary) 100 | 101 | Text(formatCurrentTime()) // Current day 102 | .font(.system(size: 76)).bold() 103 | .fontDesign(.rounded) 104 | .multilineTextAlignment(.center) 105 | .foregroundColor(.accentColorSecondary) 106 | } 107 | } 108 | } 109 | 110 | // Notification cards including image and texts 111 | // TODO: Use your own paths here 112 | @ViewBuilder 113 | private func notificationView(avatarPath: String? = nil, title: String? = nil, subtitle: String? = nil, time: String? = nil) -> some View { 114 | Rectangle() 115 | .foregroundColor(.clear) 116 | .frame(width: 326, height: 77) 117 | .background(Color(red: 0.98, green: 0.98, blue: 0.98)) 118 | .cornerRadius(19) 119 | .shadow(color: Color(red: 0.83, green: 0.83, blue: 0.83).opacity(0.5), radius: 10, x: 0, y: 9) 120 | .overlay( 121 | RoundedRectangle(cornerRadius: 19) 122 | .inset(by: 1) 123 | .stroke(.midGrey, lineWidth: 2) 124 | ) 125 | .overlay { 126 | HStack(alignment: .top, spacing: 0) { 127 | if avatarPath != nil { 128 | VStack { 129 | Spacer() 130 | ZStack(alignment: .center) { 131 | Rectangle() 132 | .foregroundColor(.white) 133 | .frame(width: 52, height: 52) 134 | .cornerRadius(60) 135 | 136 | .overlay( 137 | RoundedRectangle(cornerRadius: 60) 138 | .inset(by: 0) 139 | .stroke(.white, lineWidth: 3) 140 | .shadow(color: Color(red: 0.83, green: 0.83, blue: 0.83).opacity(0.5), radius: 4, x: 0, y: 0) 141 | ) 142 | 143 | NotificationAvatar(size: 44.0, assetName: avatarPath!) 144 | } 145 | .padding(.horizontal, 12) 146 | 147 | Spacer() 148 | } 149 | } 150 | 151 | VStack(alignment: .leading) { 152 | Spacer() 153 | Text(title ?? "").fontDesign(.rounded).font(.callout).fontWeight(.semibold) 154 | Text(subtitle ?? "").fontDesign(.rounded).font(.subheadline).fontWeight(.medium).foregroundStyle(.greyHint) 155 | Spacer() 156 | } 157 | Spacer() 158 | 159 | Text(time ?? "").fontDesign(.rounded).font(.subheadline).fontWeight(.medium).foregroundStyle(.greyHint) 160 | .padding(.trailing, 16) 161 | .padding(.top, 12) 162 | } 163 | } 164 | } 165 | 166 | private func formatCurrentDate() -> String { 167 | let dateFormatter = DateFormatter() 168 | dateFormatter.dateFormat = "EEEE, MMMM d" 169 | return dateFormatter.string(from: .now) 170 | } 171 | 172 | private func formatCurrentTime() -> String { 173 | let dateFormatter = DateFormatter() 174 | dateFormatter.dateFormat = "h:mm" // "h" for 12-hour format without leading zero, "mm" for minutes with two digits. 175 | return dateFormatter.string(from: Date()) 176 | } 177 | } 178 | 179 | // Placeholder for Avatar, so the file can run standalone. 180 | struct NotificationAvatar: View { 181 | var size: CGFloat 182 | var assetName: String 183 | 184 | var body: some View { 185 | Image(self.assetName) 186 | .resizable() 187 | .shadow(color: Color(red: 0.87, green: 0.87, blue: 0.87).opacity(0.6), radius: 5, x: 0, y: 4) 188 | .frame(width: self.size, height: self.size) 189 | } 190 | } 191 | 192 | #Preview { 193 | MainActor.assumeIsolated { 194 | let container = PreviewSampleData.container 195 | 196 | return VStack { 197 | NotificationAnimation() 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /custom_refresh_view.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomRefreshView.swift 3 | // whatsgoingon 4 | // 5 | // Created by Fabian Gruß on 13.10.2023. 6 | // 7 | 8 | import SwiftUI 9 | 10 | // Usage: 11 | /* 12 | CustomRefreshView(showsIndicator: false) { 13 | # Add content here that should be pulled down 14 | } onRefresh: { 15 | # Do something here, i.e. refresh or open something 16 | # It's possible to add a waiting state: try? await Task.sleep(nanoseconds: 1000000000) 17 | } 18 | */ 19 | 20 | 21 | struct CustomRefreshView: View { 22 | var content: Content 23 | var showsIndicator: Bool 24 | var onRefresh: () async->() 25 | 26 | init(showsIndicator: Bool = false, @ViewBuilder content: @escaping ()->Content, onRefresh: @escaping () async->()) { 27 | self.showsIndicator = showsIndicator 28 | self.content = content() 29 | self.onRefresh = onRefresh 30 | } 31 | 32 | @StateObject var scrollDelegate: ScrollViewModel = .init() 33 | 34 | var body: some View { 35 | ScrollView(.vertical, showsIndicators: showsIndicator) { 36 | VStack(spacing: 0) { 37 | GeometryReader { _ in 38 | HStack { 39 | // Keep it centered 40 | Spacer() 41 | CustomProgressView(progress: scrollDelegate.progress) 42 | Spacer() 43 | } 44 | .opacity(scrollDelegate.isEligible ? 0 : 1) 45 | .animation(.easeInOut(duration: 0.25), value: scrollDelegate.isEligible) 46 | .frame(height: 200) 47 | .opacity(scrollDelegate.progress) 48 | .offset(y: scrollDelegate.isEligible ? -(scrollDelegate.contentOffset < 0 ? 0 : scrollDelegate.contentOffset) : -(scrollDelegate.scrollOffset < 0 ? 0 : scrollDelegate.scrollOffset)) 49 | } 50 | .frame(height: 0) 51 | .offset(y: -75 + (75 * scrollDelegate.progress)) 52 | content 53 | .offset(y: scrollDelegate.progress * 100) 54 | } 55 | .offset(coordinateSpace: "SCROLL") { offset in 56 | scrollDelegate.contentOffset = offset 57 | 58 | // Checking if refresh action should be triggered 59 | if !scrollDelegate.isEligible { 60 | var progress = offset / 100 61 | progress = (progress < 0 ? 0 : progress) 62 | progress = (progress > 1 ? 1 : progress) 63 | scrollDelegate.scrollOffset = offset 64 | scrollDelegate.progress = progress 65 | 66 | // Additional haptic feedback if needed 67 | if progress >= 0.75 && !scrollDelegate.vibrateAt75 { 68 | // UIImpactFeedbackGenerator(style: .medium).impactOccurred() 69 | scrollDelegate.vibrateAt75 = true 70 | } else if progress >= 0.05 && !scrollDelegate.vibrateAt50 { 71 | UIImpactFeedbackGenerator(style: .medium).impactOccurred() 72 | scrollDelegate.vibrateAt50 = true 73 | } else if progress >= 0.25 && !scrollDelegate.vibrateAt25 { 74 | // UIImpactFeedbackGenerator(style: .soft).impactOccurred() 75 | scrollDelegate.vibrateAt25 = true 76 | } 77 | } 78 | 79 | // Additional haptic feedback at "success" 80 | if scrollDelegate.isEligible && !scrollDelegate.isRefreshing { 81 | scrollDelegate.isRefreshing = true 82 | UIImpactFeedbackGenerator(style: .heavy).impactOccurred() 83 | } 84 | } 85 | } 86 | .coordinateSpace(name: "SCROLL") 87 | .onAppear(perform: scrollDelegate.addGesture) 88 | .onDisappear(perform: scrollDelegate.removeGesture) 89 | .onChange(of: scrollDelegate.isRefreshing) { 90 | if scrollDelegate.isRefreshing { 91 | scrollDelegate.vibrateAt25 = false 92 | scrollDelegate.vibrateAt50 = false 93 | scrollDelegate.vibrateAt75 = false 94 | Task { 95 | // Trigger refresh action 96 | await onRefresh() 97 | withAnimation(.easeInOut(duration: 0.25)) { 98 | scrollDelegate.progress = 0 99 | scrollDelegate.isEligible = false 100 | scrollDelegate.isRefreshing = false 101 | scrollDelegate.scrollOffset = 0 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | 109 | // Previews if needed 110 | struct CustomRefreshView_Previews: PreviewProvider { 111 | static var previews: some View { 112 | CustomRefreshView(showsIndicator: false) { 113 | Rectangle() 114 | .fill(Color.red) 115 | .frame(width: 200, height: 200) 116 | } onRefresh: { 117 | try? await Task.sleep(nanoseconds: 1000000000) 118 | } 119 | } 120 | } 121 | 122 | 123 | class ScrollViewModel: NSObject, ObservableObject, UIGestureRecognizerDelegate { 124 | 125 | // MARK: Properties 126 | @Published var isEligible: Bool = false 127 | @Published var isRefreshing: Bool = false 128 | 129 | // MARK: Gesture Properties 130 | @Published var scrollOffset: CGFloat = 0 131 | @Published var contentOffset: CGFloat = 0 132 | @Published var progress: CGFloat = 0 133 | let gestureID: String = UUID().uuidString 134 | 135 | // MARK: Haptic Feedback Properties 136 | @Published var vibrateAt25: Bool = false 137 | @Published var vibrateAt50: Bool = false 138 | @Published var vibrateAt75: Bool = false 139 | 140 | // Adding Pan Gesture To UI Main Application Window 141 | // With Simultaneous Gesture Desture 142 | // Thus it Wont disturb SwiftUI Scroll's And Gesture's 143 | func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer)->Bool { 144 | return true 145 | } 146 | 147 | // MARK: Adding Gesture 148 | func addGesture() { 149 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onGestureChange(gesture:))) 150 | panGesture.delegate = self 151 | panGesture.name = gestureID 152 | rootController().view.addGestureRecognizer(panGesture) 153 | } 154 | 155 | // MARK: Removing When Leaving The View 156 | func removeGesture() { 157 | rootController().view.gestureRecognizers?.removeAll(where: { gesture in 158 | gesture.name == gestureID 159 | }) 160 | } 161 | 162 | // MARK: Finding Root Controller 163 | func rootController()->UIViewController { 164 | guard let screen = UIApplication.shared.connectedScenes.first as? UIWindowScene else { 165 | return .init() 166 | } 167 | 168 | guard let root = screen.windows.first?.rootViewController else { 169 | return .init() 170 | } 171 | 172 | return root 173 | } 174 | 175 | @objc 176 | func onGestureChange(gesture: UIPanGestureRecognizer) { 177 | if gesture.state == .cancelled || gesture.state == .ended { 178 | // User released touch here 179 | if !isRefreshing { 180 | if scrollOffset > 100 { 181 | isEligible = true 182 | } else { 183 | isEligible = false 184 | } 185 | } 186 | } 187 | } 188 | } 189 | 190 | // Extension to observe changes in the offset of a view 191 | extension View { 192 | @ViewBuilder 193 | func offset(coordinateSpace: String, offset: @escaping (CGFloat)->())->some View { 194 | overlay { 195 | GeometryReader { proxy in 196 | let minY = proxy.frame(in: .named(coordinateSpace)).minY 197 | Color.clear 198 | .preference(key: RefreshOffsetKey.self, value: minY) 199 | .onPreferenceChange(RefreshOffsetKey.self) { value in 200 | offset(value) 201 | } 202 | } 203 | } 204 | } 205 | } 206 | 207 | // A preference key used to store the minimum Y offset of a view 208 | struct RefreshOffsetKey: PreferenceKey { 209 | static var defaultValue: CGFloat = 0 210 | 211 | static func reduce(value: inout CGFloat, nextValue: ()->CGFloat) { 212 | value = nextValue() 213 | } 214 | } 215 | 216 | 217 | // Your custom view that is shown when the user pulls down. Gets a progress value from 0 to 1. 218 | struct CustomProgressView: View { 219 | var progress: CGFloat 220 | var body: some View { 221 | VStack(spacing: 12) { 222 | ZStack { 223 | Circle() 224 | .stroke(.clear.opacity(0.2), lineWidth: 5) 225 | .frame(width: 50, height: 50) 226 | Circle() 227 | .trim(from: 0, to: progress) 228 | .stroke(.accentColorPrimary, style: StrokeStyle(lineWidth: 5, lineCap: .round)) 229 | .rotationEffect(.degrees(-90)) 230 | .frame(width: 50, height: 50) 231 | .shadow(color: Color(red: 0.48, green: 0.57, blue: 1).opacity(0.4), radius: 4, x: 0, y: 3) 232 | 233 | // V1 with simple plus 234 | /* Image(systemName: "plus") 235 | .font(.system(size: 16 * (0.6 + progress), weight: .semibold)) 236 | .foregroundColor(.accentColorPrimary).opacity((0.3 + progress) > 1 ? 1 : (0.3 + progress)) */ 237 | 238 | // Var 2: Filled 239 | Image(systemName: "plus.circle.fill") 240 | .font(.system(size: 32 * (0.6 + progress))) 241 | .foregroundColor(.accentColorPrimary).opacity((0.6 + progress) > 1 ? 1 : (0.6 + progress)) 242 | } 243 | Text("Pull to add update").contentSubtitle() 244 | } 245 | } 246 | } 247 | --------------------------------------------------------------------------------