What State/Binding do I need to use? - ios

I want to create a Timer that is showing in a Slider.
I got this Code:
struct CustomSlider : View {
#State var value: Double = 0.0
init() {
let thumbImage = UIImage(systemName: "circle.fill")
UISlider.appearance().setThumbImage(thumbImage, for: .normal)
}
var body: some View {
Slider(value: $value)
}
}
struct MusicPlayerView: View {
#State var value: Double = 0.0
let timer = Timer
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()
var body: some View {
CustomSlider()
.onReceive(timer) { _ in
guard let player = audioManager.player else {return}
value = player.currentTime
}
}
}
How do I pass the value from MusicPlayerView to CustomSlider? I tried using #Bindingin the CustomSliderbut then it gives me the error:
Return from initializer without initializing all stored properties
in CustomSlider

When using #Binding you need to update your init:
struct CustomSlider : View {
#Binding var value: Double
init(value: Binding<Double>) {
self._value = value
let thumbImage = UIImage(systemName: "circle.fill")
UISlider.appearance().setThumbImage(thumbImage, for: .normal)
}
var body: some View {
Slider(value: $value)
}
}
And in MusicPlayerView:
struct MusicPlayerView: View {
#State var value: Double = 0.0
let timer = Timer
.publish(every: 0.5, on: .main, in: .common)
.autoconnect()
var body: some View {
CustomSlider(value: $value)
.onReceive(timer) { _ in
guard let player = audioManager.player else {return}
value = player.currentTime
}
}
}

Related

Change a property on TabView drag gesture in SwiftUI (View pager)

I have written a generic ViewPager with TabView and it works perfectly. However, I want to pause the timer (auto swipe) when user starts dragging and resume it when user finishes the dragging. Is there anyway to do that?
This is my ViewPager:
struct ViewPager<Data, Content> : View
where Data : RandomAccessCollection, Data.Element : Identifiable, Content : View {
private var timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect()
#Binding var currentIndex: Int
private let data: [Data.Element]
private let content: (Data.Element) -> Content
private let isTimerEnabled: Bool
private let showIndicator: PageTabViewStyle.IndexDisplayMode
init(_ data: Data,
currentIndex: Binding<Int>,
isTimerEnabled: Bool = false,
showIndicator: PageTabViewStyle.IndexDisplayMode = .never,
#ViewBuilder content: #escaping (Data.Element) -> Content) {
_currentIndex = currentIndex
self.data = data.map { $0 }
self.content = content
self.isTimerEnabled = isTimerEnabled
self.showIndicator = showIndicator
}
private var totalCount: Int {
data.count
}
var body: some View {
TabView(selection: $currentIndex) {
ForEach(data) { item in
self.content(item)
.tag(item.id)
}
}.tabViewStyle(PageTabViewStyle(indexDisplayMode: showIndicator))
.onReceive(timer) { _ in
if !isTimerEnabled {
timer.upstream.connect().cancel()
} else {
withAnimation {
currentIndex = currentIndex < (totalCount - 1) ? currentIndex + 1 : 0
}
}
}
}
}
To "pause" while user is dragging, you can exchange .common with .default in the Timer. But what you probably also want is setting the timer back to 2 secs once the dragging is over ...
I got this to work but I use a global var, so the timer stays around and this feels wrong – can someone help further?
// global var – this seems wrong, but works
var timer = Timer.publish(every: 2, on: .main, in: .default).autoconnect()
struct ViewPager<Data, Content> : View
where Data : RandomAccessCollection, Data.Element : Identifiable, Content : View {
#Binding var currentIndex: Int
private let data: [Data.Element]
private let content: (Data.Element) -> Content
private let isTimerEnabled: Bool
private let showIndicator: PageTabViewStyle.IndexDisplayMode
init(_ data: Data,
currentIndex: Binding<Int>,
isTimerEnabled: Bool = false,
showIndicator: PageTabViewStyle.IndexDisplayMode = .never,
#ViewBuilder content: #escaping (Data.Element) -> Content) {
_currentIndex = currentIndex
self.data = data.map { $0 }
self.content = content
self.isTimerEnabled = isTimerEnabled
self.showIndicator = showIndicator
}
private var totalCount: Int {
data.count
}
var body: some View {
TabView(selection: $currentIndex) {
ForEach(data) { item in
self.content(item)
.tag(item.id)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: showIndicator))
.onReceive(timer) { _ in
if !isTimerEnabled {
timer.upstream.connect().cancel()
} else {
print("received")
withAnimation {
currentIndex = currentIndex < (totalCount - 1) ? currentIndex + 1 : 0
print(currentIndex)
}
}
}
.onChange(of: currentIndex) { _ in
timer = Timer.publish(every: 2, on: .main, in: .default).autoconnect()
}
}
}
same principle but used selection for TabView (and tag for view) and timer as not global var
struct MotivationTabView: View {
// MARK: - PROPERTIES
#State private var selectedItem = "Adolf Dobr’aňskŷj"
#State private var isTimerEnabled: Bool = true
#State private var timer = Timer.publish(every: 10, on: .main, in: .default).autoconnect()
let items: KeyValuePairs = ["Adolf Dobr’aňskŷj": "Svij narod treba ľubyty i ne haňbyty s’a za ňoho!",
"Fjodor Mychailovič Dostojevskŷj": "Chto ne maje narod, tot ne maje any Boha! Buďte sobi istŷ, že všŷtkŷ totŷ, što perestanuť rozumity svomu narodu i stračajuť z nym perevjazaňa, stračajuť jednočasno viru otc’ovsku, stavajuť buď ateistamy, abo cholodnŷma.",
"Pau del Rosso": "Je barz važnŷm uchovaty sobi vlastnu identičnosť. To naš unikatnŷj dar pro druhŷch, unikatnŷj v cilim kozmosi.",
"Viktor Hugo": "Velykosť naroda ne mir’ať s’a kiľkosťov, tak jak i velykosť čolovika ne mir’ať s’a vŷškov.",
"Lewis Lapham":"Strata identitŷ je vŷhoda pro biznis... pokľa bŷ jem znav, chto jem, čom bŷ jem si bezprestajno kupovav novŷ značkŷ vodŷ po holiňu?"]
private var totalCount: Int {
items.count
}
private func nextItem(currItem: String) -> String{
switch currItem {
case "Adolf Dobr’aňskŷj": return "Fjodor Mychailovič Dostojevskŷj"
case "Fjodor Mychailovič Dostojevskŷj": return "Pau del Rosso"
case "Pau del Rosso": return "Viktor Hugo"
case "Viktor Hugo": return "Lewis Lapham"
default: return "Adolf Dobr’aňskŷj"
}
}
// MARK: - BODY
var body: some View {
GroupBox {
TabView(selection: $selectedItem){
ForEach(items, id: \.self.key) { item in
VStack(alignment: .trailing){
Text(item.value)
Text(item.key)
.font(.caption)
.padding(.top, 5)
}.tag(item.key)
}
} //: TABS
.tabViewStyle(PageTabViewStyle())
.frame(height: 240)
.onReceive(timer) { _ in
if !isTimerEnabled {
timer.upstream.connect().cancel()
} else {
print("received")
withAnimation() {
selectedItem = nextItem(currItem: selectedItem)
print(selectedItem)
}
}
}
.onAppear{
isTimerEnabled = true
timer = Timer.publish(every: 10, on: .main, in: .default).autoconnect()
}
.onDisappear{
isTimerEnabled = false
}
} //: BOX
}

Audio Player in swift is not getting value of volume and pitch

I am trying to make an audio player in SwiftUI, Audio player should have these functionality.
Play/Stop Audio
Play in loop
Change volume through slider
Change audio pitch through slider.
There are two problem currently I am facing
audio player is not using volume and pitch slider value
While I stop and play and change volume/pitch slider app crashes with following message.
2020-10-14 17:34:08.957709+0530 SwiftUIAudioPlayer[1369:24886] [avae]
AVAEInternal.h:109 [AVAudioFile.mm:484:-[AVAudioFile
readIntoBuffer:frameCount:error:]:
(ExtAudioFileRead(_imp->_extAudioFile, &ioFrames,
buffer.mutableAudioBufferList)): error -50
Here is the link to project. https://github.com/varun-naharia/SwiftUIAudioPlayer
ContentView.swift
import Foundation
import SwiftUI
struct ContentView: View {
#State var volume:Double = 0.00
#State var pitch:Double = 0.0
#State var musicFiles:[SoundModel] = [SoundModel(file: "metro35", name: "Metronome", fileExtension: "wav"), SoundModel(file: "johnson_tone_down_5min", name: "Johnson", fileExtension: "wav"), SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")]
#State var selectedMusicFile:SoundModel = SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")
#State var showSoundPicker = false
#State var selectedGraph = "skin_conductance"
#State var iconSize:CGFloat = 0.124
#State var iconSpace:CGFloat = 0.015
#State var heart = false
init() {
Player.setPitch(pitch: Float(self.pitch))
Player.setVolume(volume: Float(self.volume))
}
var body: some View {
GeometryReader { geometry in
ZStack{
VStack(alignment: .leading) {
Button(action: {
self.heart = !self.heart
self.selectedGraph = "heart"
if(self.heart)
{
Player.playMusic(musicfile: self.selectedMusicFile.file, fileExtension: self.selectedMusicFile.fileExtension)
}
else
{
Player.stopMusic()
self.selectedGraph = ""
}
})
{
Image(self.selectedGraph == "heart" ? "heart" : "heart_disabled")
.resizable()
.frame(width: geometry.size.height*self.iconSize, height: geometry.size.height*self.iconSize)
}
.frame(width: geometry.size.height*self.iconSize, height: geometry.size.height*self.iconSize)
.padding(.bottom, geometry.size.height*(self.iconSpace/2))
Button(action: {
self.showSoundPicker = !self.showSoundPicker
})
{
Image("tone")
.resizable()
.frame(width: geometry.size.height*self.iconSize, height: geometry.size.height*self.iconSize)
}
.frame(width: geometry.size.height*self.iconSize, height: geometry.size.height*self.iconSize)
.padding(.bottom, geometry.size.height*(self.iconSpace/2))
HStack{
SwiftUISlider(
thumbColor: .green,
thumbImage: "musicNote 2",
value: self.$volume
).padding(.horizontal)
Button(action: {
})
{
Image("centerGraph")
.resizable()
.frame(width: geometry.size.width*0.05, height: geometry.size.width*0.05)
}
.frame(width: geometry.size.width*0.03, height: geometry.size.width*0.03)
SwiftUISlider(
thumbColor: .green,
thumbImage: "timerSlider 2",
minValue: 0,
maxValue: 20,
value: self.$pitch
)
.padding(.horizontal)
.frame(width: (geometry.size.width/2)-geometry.size.width*0.05, height: geometry.size.width*0.05)
}
.background(Color(UIColor.lightGray))
.frame(width: geometry.size.width, height: geometry.size.height*0.10)
if(self.showSoundPicker)
{
ChooseSoundView(
musicFiles: self.musicFiles,
selectedMusicFile: self.$selectedMusicFile ,
showSoundPicker: self.$showSoundPicker,
isPlaying: self.selectedGraph != ""
)
.frame(width: geometry.size.width*0.6, height: geometry.size.height*0.7, alignment: .center)
.background(Color.white)
}
}
.frame(maxWidth: geometry.size.width,
maxHeight: geometry.size.height)
.background(Color(UIColor.lightGray))
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
struct ChooseSoundView: View {
#State var musicFiles:[SoundModel]
#Binding var selectedMusicFile:SoundModel
#Binding var showSoundPicker:Bool
#State var isPlaying:Bool
var body: some View {
GeometryReader { geometry in
VStack(alignment: .leading)
{
List(self.musicFiles, id: \.name)
{ item in
Image(self.selectedMusicFile.file == item.file ? "radio-button_on" : "radio-button_off")
.resizable()
.frame(width: 15, height: 15)
Button(action: {
print(item.name)
self.selectedMusicFile = item
self.showSoundPicker = false
if(self.isPlaying)
{
// Player.stopMusic()
// Player.playMusic(musicfile: self.selectedMusicFile.file, fileExtension: self.selectedMusicFile.fileExtension)
}
}){
Text(item.name)
.frame(width: geometry.size.width*90,
height: 50.0,
alignment: .leading)
}
.frame(width: geometry.size.width*90, height: 50.0)
}
HStack{
Button(action: {
self.showSoundPicker = false
}){
Text("Done")
.frame(width: geometry.size.width*0.45,
height: 50.0,
alignment: .center)
}
.frame(width: geometry.size.width*0.45, height: 50.0)
Button(action: {
self.showSoundPicker = false
}){
Text("Cancel")
.frame(width: geometry.size.width*0.45,
height: 50.0,
alignment: .center)
}
.frame(width: geometry.size.width*0.45, height: 50.0)
}
.background(Color.white)
}
}
}
}
Player.swift
import Foundation
import AVFoundation
class Player {
private static var breathAudioPlayer:AVAudioPlayer?
private static var audioPlayerEngine = AVAudioEngine()
private static let speedControl = AVAudioUnitVarispeed()
private static var pitchControl = AVAudioUnitTimePitch()
private static var audioPlayerNode = AVAudioPlayerNode()
private static var volume:Float = 1.0
private static func playSounds(soundfile: String) {
if let path = Bundle.main.path(forResource: soundfile, ofType: "m4a"){
do{
breathAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
breathAudioPlayer?.volume = self.volume
breathAudioPlayer?.prepareToPlay()
breathAudioPlayer?.play()
}catch {
print("Error")
}
}
}
static func playMusic(musicfile: String, fileExtension:String) {
if let path = Bundle.main.path(forResource: musicfile, ofType: fileExtension){
do{
// 1: load the file
let audioPlayFile = try AVAudioFile(forReading: URL(fileURLWithPath: path))
let audioFileBuffer = AVAudioPCMBuffer(pcmFormat: audioPlayFile.fileFormat, frameCapacity: AVAudioFrameCount(audioPlayFile.length))
try? audioPlayFile.read(into: audioFileBuffer!)
// 2: create the audio player
audioPlayerNode = AVAudioPlayerNode()
audioPlayerEngine = AVAudioEngine()
// you can replace mp3 with anything else you like, just make sure you load it from our project
// making sure to clean up the audio hardware to avoid any damage and bugs
audioPlayerNode.stop()
audioPlayerEngine.stop()
audioPlayerEngine.reset()
audioPlayerEngine.attach(audioPlayerNode)
let pitchControl = AVAudioUnitTimePitch()
// assign the speed and pitch
audioPlayerEngine.attach(pitchControl)
audioPlayerEngine.connect(audioPlayerNode, to: pitchControl, format: nil)
audioPlayerEngine.connect(pitchControl, to: audioPlayerEngine.outputNode, format: nil)
audioPlayerNode.scheduleFile(audioPlayFile, at: nil, completionHandler: nil)
// try to start playing the audio
audioPlayerNode.scheduleBuffer(audioFileBuffer!, at: nil, options: .loops, completionHandler: nil)
do {
try audioPlayerEngine.start()
} catch {
print(error)
}
// play the audio
audioPlayerNode.play()
}catch {
print("Error")
}
}
}
static func breathIn() {
// Player.playSounds(soundfile: "breathin")
}
static func breathOut() {
// Player.playSounds(soundfile: "breathout")
}
static func play(musicFile:String, fileExtension:String)
{
Player.playMusic(musicfile: musicFile,fileExtension: fileExtension)
}
static func stopMusic() {
audioPlayerNode.pause()
audioPlayerNode.stop()
}
static func setPitch(pitch:Float) {
pitchControl.pitch = pitch
}
static func setVolume(volume:Float) {
audioPlayerNode.volume = volume
}
}
SwiftUISlider.swift
import Foundation
import SwiftUI
struct SwiftUISlider: UIViewRepresentable {
var onChangeNotification:String = ""
final class Coordinator: NSObject {
// The class property value is a binding: It’s a reference to the SwiftUISlider
// value, which receives a reference to a #State variable value in ContentView.
var value: Binding<Double>
// Create the binding when you initialize the Coordinator
init(value: Binding<Double>) {
self.value = value
}
// Create a valueChanged(_:) action
#objc func valueChanged(_ sender: UISlider) {
self.value.wrappedValue = Double(sender.value)
}
}
var thumbColor: UIColor = .white
var minTrackColor: UIColor?
var maxTrackColor: UIColor?
var thumbImage:String?
var minValue:Float?
var maxValue:Float?
#Binding var value: Double
func makeUIView(context: Context) -> UISlider {
let slider = UISlider(frame: .zero)
slider.thumbTintColor = thumbColor
slider.minimumTrackTintColor = minTrackColor
slider.maximumTrackTintColor = maxTrackColor
slider.value = Float(value)
if(self.minValue != nil)
{
slider.minimumValue = self.minValue!
}
if(self.maxValue != nil)
{
slider.maximumValue = self.maxValue!
}
slider.setThumbImage(UIImage(named: self.thumbImage ?? ""), for: .normal)
slider.setThumbImage(UIImage(named: self.thumbImage ?? ""), for: .focused)
slider.setThumbImage(UIImage(named: self.thumbImage ?? ""), for: .highlighted)
slider.addTarget(
context.coordinator,
action: #selector(Coordinator.valueChanged(_:)),
for: .valueChanged
)
return slider
}
func onValueChange(_ sender: UISlider) {
}
func updateUIView(_ uiView: UISlider, context: Context) {
// Coordinating data between UIView and SwiftUI view
uiView.value = Float(self.value)
}
func makeCoordinator() -> SwiftUISlider.Coordinator {
Coordinator(value: $value)
}
}
SoundModel.swift
import Foundation
import Combine
class SoundModel:ObservableObject, Identifiable
{
#Published var file:String
#Published var name:String
#Published var fileExtension:String
init(file:String, name:String, fileExtension:String) {
self.file = file
self.name = name
self.fileExtension = fileExtension
}
}
Your first problem is that you're not tracking changes of volume/pitch values. To do so move them to a class:
class PlayerSetup: ObservableObject {
#Published var volume:Double = 0.00 {
didSet {
Player.setVolume(volume: Float(self.volume))
}
}
#Published var pitch:Double = 0.0{
didSet {
Player.setPitch(pitch: Float(self.pitch))
}
}
}
Declare in the view:
#ObservedObject var playerSetup = PlayerSetup()
And bind to your views:
SwiftUISlider(
thumbColor: .green,
thumbImage: "musicNote 2",
value: $playerSetup.volume
).padding(.horizontal)
SwiftUISlider(
thumbColor: .green,
thumbImage: "timerSlider 2",
minValue: 0,
maxValue: 20,
value: $playerSetup.pitch
)
it crashes when finish playing the file because try? audioPlayFile.read(into: audioFileBuffer!) fails and your buffer scheduled after the file is empty. and it plays the file for the first time because of scheduleFile. If you wanna loop single file, try calling this function:
static func scheduleNext(audioPlayFile: AVAudioFile) {
audioPlayerNode.scheduleFile(audioPlayFile, at: nil) {
DispatchQueue.main.async {
scheduleNext(audioPlayFile: audioPlayFile)
}
}
}
pitchControl doesn't work because you're using local value when starting playing, just remove the local value declaration.
about to the volume. as you can see in the documentation, This property is implemented only by the AVAudioEnvironmentNode and AVAudioMixerNode class mixers. So you can't use it for a player node, you need to create a mixer node, add it to the chain of nodes, and change it's volume.
Also to clean up nodes code, I advice you to use the following code:
let nodes = [
audioPlayerNode,
pitchControl,
mixerNode,
]
nodes.forEach { node in
audioPlayerEngine.attach(node)
}
zip(nodes, (nodes.dropFirst() + [audioPlayerEngine.outputNode]))
.forEach { firstNode, secondNode in
audioPlayerEngine.connect(firstNode, to: secondNode, format: nil)
}
it connects all nodes one by one.
https://github.com/PhilipDukhov/SwiftUIAudioPlayer/tree/fixes
Main problem of your code that there is mixed view and view model between them. Lot of things that MUST BE in viewModel at the moment located in the View.
View:
struct ContentView: View {
#State var volume:Double = 0.00
#State var pitch:Double = 0.0
#State var musicFiles:[SoundModel] = [SoundModel(file: "metro35", name: "Metronome", fileExtension: "wav"), SoundModel(file: "johnson_tone_down_5min", name: "Johnson", fileExtension: "wav"), SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")]
#State var selectedMusicFile:SoundModel = SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")
#State var showSoundPicker = false
#State var selectedGraph = "skin_conductance"
#State var iconSize:CGFloat = 0.124
#State var iconSpace:CGFloat = 0.015
#State var heart = false
init() {
Player.setPitch(pitch: Float(self.pitch))
Player.setVolume(volume: Float(self.volume))
}
view must be:
struct PlayerView: View {
#ObservedObject var viewModel: PlayerViewModel
#State var iconSize:CGFloat = 0.124
#State var iconSpace:CGFloat = 0.015
init() {
// that was wrote here must be moved to init of playerViewModel
}
viewModel your:
import Foundation
import AVFoundation
class Player {
private static var breathAudioPlayer:AVAudioPlayer?
private static var audioPlayerEngine = AVAudioEngine()
private static let speedControl = AVAudioUnitVarispeed()
private static var pitchControl = AVAudioUnitTimePitch()
private static var audioPlayerNode = AVAudioPlayerNode()
private static var volume:Float = 1.0
private static func playSounds(soundfile: String) {
....
ViewModel must be:
import Foundation
import AVFoundation
class PlayerViewModel: ObservableObject {
#Published var volume:Double = 0.00
#Published var pitch:Double = 0.0
#Published var musicFiles:[SoundModel] = [SoundModel(file: "metro35", name: "Metronome", fileExtension: "wav"), SoundModel(file: "johnson_tone_down_5min", name: "Johnson", fileExtension: "wav"), SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")]
#Published var selectedMusicFile:SoundModel = SoundModel(file: "sine_140_6s_fade_ogg", name: "Sine wave", fileExtension: "wav")
#Published var showSoundPicker = false
#Published var selectedGraph = "skin_conductance"
//#Published var heart = false // SoundViewModel's property
private static var breathAudioPlayer:AVAudioPlayer?
private static var audioPlayerEngine = AVAudioEngine()
private static let speedControl = AVAudioUnitVarispeed()
private static var pitchControl = AVAudioUnitTimePitch()
private static var audioPlayerNode = AVAudioPlayerNode()
private static var volume:Float = 1.0
private static func playSounds(soundfile: String) {
If you will move all of this to proper place, almost sure your code will work better and possibly your problem will be fixed.
at the moment you need to track any changes manually. Your structure of code is the reason of this. You do not need to track this manually because of this is additional useless code.
You need to fix the code structure instead of use "didSet"

How to navigate another view in onReceive timer closure SwiftUI iOS

I am trying to achieve a navigation to another view when timer hits a specific time. For example I want to navigate to another view after 5 minutes. In swift i can easily achieve this but I am new to SwiftUI and little help will be highly appreciated.
My code:
struct TwitterWebView: View {
#State var timerTime : Float
#State var minute: Float = 0.0
#State private var showLinkTarget = false
let timer = Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()
var body: some View {
WebView(url: "https://twitter.com/")
.navigationBarTitle("")
.navigationBarHidden(true)
.onReceive(timer) { _ in
if self.minute == self.timerTime {
print("Timer completed navigate to Break view")
NavigationLink(destination: BreakView()) {
Text("Test")
}
self.timer.upstream.connect().cancel()
} else {
self.minute += 1.0
}
}
}
}
Here is possible approach (of course assuming that TwitterWebView has NavigationView somewhere in parents)
struct TwitterWebView: View {
#State var timerTime : Float
#State var minute: Float = 0.0
#State private var showLinkTarget = false
let timer = Timer.publish(every: 60.0, on: .main, in: .common).autoconnect()
#State private var shouldNavigate = false
var body: some View {
WebView(url: "https://twitter.com/")
.navigationBarTitle("")
.navigationBarHidden(true)
.background(
NavigationLink(destination: BreakView(),
isActive: $shouldNavigate) { EmptyView() }
)
.onReceive(timer) { _ in
if self.minute == self.timerTime {
print("Timer completed navigate to Break view")
self.timer.upstream.connect().cancel()
self.shouldNavigate = true // << here
} else {
self.minute += 1.0
}
}
}
}

How could I simply have a View transition to SwiftUI View?

I'd like to implement a simple view transition through SwiftUI and Timer.
I have a primary View, it's content View. If I call func FireTimer() from in the View, the function fires timer. Then after 5 seconds, I would have a View transition.
I tried NavigationLink, but it has a button. Timer can't push the button so now I'm confused.
I'll show my code below.
TimerFire.swift
import Foundation
import UIKit
import SwiftUI
let TIME_MOVENEXT = 5
var timerCount : Int = 0
class TimerFire : ObservableObject{
var workingTimer = Timer()
#objc func FireTimer() {
print("FireTimer")
workingTimer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(TimerFire.timerUpdate),
userInfo: nil,
repeats: true)
}
#objc func timerUpdate(timeCount: Int) {
timerCount += 1
let timerText = "timerCount:\(timerCount)"
print(timerText)
if timerCount == TIME_MOVENEXT {
print("timerCount == TIME_MOVENEXT")
workingTimer.invalidate()
print("workingTimer.invalidate()")
timerCount = 0
//
//want to have a transition to SecondView here
//
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
var body: some View {
Button(action: {
// What to perform
let timerFire = TimerFire()
timerFire.FireTimer()
}) {
// How the button looks like
Text("Fire timer")
}
}
}
SecondView.swift
import Foundation
import SwiftUI
struct SecondView: View {
var body: some View {
Text("Second World")
}
}
How could I simply show this SecondView?
Ok, if you want to do this w/o NavigationView on first screen (for any reason) here is a possible approach based on transition between two views.
Note: Preview has limited support for transitions, so please test on Simulator & real device
Here is a demo how it looks (initial white screen is just Simulator launch)
Here is single testing module:
import SwiftUI
import UIKit
let TIME_MOVENEXT = 5
var timerCount : Int = 0
class TimerFire : ObservableObject{
var workingTimer = Timer()
#Published var completed = false
#objc func FireTimer() {
print("FireTimer")
workingTimer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(TimerFire.timerUpdate),
userInfo: nil,
repeats: true)
}
#objc func timerUpdate(timeCount: Int) {
timerCount += 1
let timerText = "timerCount:\(timerCount)"
print(timerText)
if timerCount == TIME_MOVENEXT {
print("timerCount == TIME_MOVENEXT")
workingTimer.invalidate()
print("workingTimer.invalidate()")
timerCount = 0
//
//want to have a transition to SecondView here
//
self.completed = true
}
}
}
struct SecondView: View {
var body: some View {
Text("Second World")
}
}
struct TestTransitionByTimer: View {
#ObservedObject var timer = TimerFire()
#State var showDefault = true
var body: some View {
ZStack {
Rectangle().fill(Color.clear) // << to make ZStack full-screen
if showDefault {
Rectangle().fill(Color.blue) // << just for demo
.overlay(Text("Hello, World!"))
.transition(.move(edge: .leading))
}
if !showDefault {
Rectangle().fill(Color.red) // << just for demo
.overlay(SecondView())
.transition(.move(edge: .trailing))
}
}
.onAppear {
self.timer.FireTimer()
}
.onReceive(timer.$completed, perform: { completed in
withAnimation {
self.showDefault = !completed
}
})
}
}
struct TestTransitionByTimer_Previews: PreviewProvider {
static var previews: some View {
TestTransitionByTimer()
}
}
There is no code snippet for ContentView, so I tried to build simple example by myself. You can use NavigationLink(destination: _, isActive: Binding<Bool>, label: () -> _) in your case. Change some State var while receiving changes from Timer.publish and you'll go to SecondView immediately:
struct TransitionWithTimer: View {
#State var goToSecondWorld = false
let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect()
var body: some View {
NavigationView {
VStack {
NavigationLink(destination: SecondWorld(), isActive: self.$goToSecondWorld) {
Text("First World")
.onReceive(timer) { _ in
self.goToSecondWorld = true
}
}
}
}
}
}
// you can use ZStack and opacity/offset of view's:
struct TransitionWithTimerAndOffset: View {
#State var goToSecondWorld = false
let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Text("First world") // here can be your first View
.opacity(self.goToSecondWorld ? 0 : 1)
.offset(x: goToSecondWorld ? 1000 : 0)
Text("Second world") // and here second world View
.opacity(self.goToSecondWorld ? 1 : 0)
.offset(x: goToSecondWorld ? 0 : -1000)
}
.onReceive(timer) { _ in
withAnimation(.spring()) {
self.goToSecondWorld = true
}
}
}
}
struct SecondWorld: View {
var body: some View {
Text("Second World")
}
}

SwiftUI custom stepper button

I'm creating a custom stepper control in SwiftUI, and I'm trying to replicate the accelerating value change behavior of the built-in control. In a SwiftUI Stepper, long pressing on "+" or "-" will keep increasing/decreasing the value with the rate of change getting faster the longer you hold the button.
I can create the visual effect of holding down the button with the following:
struct PressBox: View {
#GestureState var pressed = false
#State var value = 0
var body: some View {
ZStack {
Rectangle()
.fill(pressed ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(LongPressGesture(minimumDuration: .infinity)
.updating($pressed) { value, state, transaction in
state = value
}
.onChanged { _ in
self.value += 1
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
}
This only increments the value once. Adding a timer publisher to the onChanged modifier for the gesture like this:
let timer = Timer.publish(every: 0.5, on: .main, in: .common)
#State var cancellable: AnyCancellable? = nil
...
.onChanged { _ in
self.cancellable = self.timer.connect() as? AnyCancellable
}
will replicate the changing values, but since the gesture never completes successfully (onEnded will never be called), there's no way to stop the timer. Gestures don't have an onCancelled modifier.
I also tried doing this with a TapGesture which would work for detecting the end of the gesture, but I don't see a way to detect the start of the gesture. This code:
.gesture(TapGesture()
.updating($pressed) { value, state, transaction in
state = value
}
)
generates an error on $pressed:
Cannot convert value of type 'GestureState' to expected argument type 'GestureState<_>'
Is there a way to replicate the behavior without falling back to UIKit?
You'd need an onTouchDown event on the view to start a timer and an onTouchUp event to stop it. SwiftUI doesn't provide a touch down event at the moment, so I think the best way to get what you want is to use the DragGesture this way:
import SwiftUI
class ViewModel: ObservableObject {
private static let updateSpeedThresholds = (maxUpdateSpeed: TimeInterval(0.05), minUpdateSpeed: TimeInterval(0.3))
private static let maxSpeedReachedInNumberOfSeconds = TimeInterval(2.5)
#Published var val: Int = 0
#Published var started = false
private var timer: Timer?
private var currentUpdateSpeed = ViewModel.updateSpeedThresholds.minUpdateSpeed
private var lastValueChangingDate: Date?
private var startDate: Date?
func start() {
if !started {
started = true
val = 0
startDate = Date()
startTimer()
}
}
func stop() {
timer?.invalidate()
currentUpdateSpeed = Self.updateSpeedThresholds.minUpdateSpeed
lastValueChangingDate = nil
started = false
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: Self.updateSpeedThresholds.maxUpdateSpeed, repeats: false) {[unowned self] _ in
self.updateVal()
self.updateSpeed()
self.startTimer()
}
}
private func updateVal() {
if self.lastValueChangingDate == nil || Date().timeIntervalSince(self.lastValueChangingDate!) >= self.currentUpdateSpeed {
self.lastValueChangingDate = Date()
self.val += 1
}
}
private func updateSpeed() {
if self.currentUpdateSpeed < Self.updateSpeedThresholds.maxUpdateSpeed {
return
}
let timePassed = Date().timeIntervalSince(self.startDate!)
self.currentUpdateSpeed = timePassed * (Self.updateSpeedThresholds.maxUpdateSpeed - Self.updateSpeedThresholds.minUpdateSpeed)/Self.maxSpeedReachedInNumberOfSeconds + Self.updateSpeedThresholds.minUpdateSpeed
}
}
struct ContentView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
Rectangle()
.fill(viewModel.started ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.viewModel.start()
}
.onEnded { _ in
self.viewModel.stop()
}
)
Text("\(viewModel.val)")
.foregroundColor(.white)
}
}
}
#if DEBUG
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(viewModel: ViewModel())
}
}
#endif
Let me know if I got what you wanted or whether I can improve my answer somehow.
For anyone attempting something similar, here's a slightly different take on superpuccio's approach. The api for users of the type is a bit more straightforward, and it minimizes the number of timer fires as the speed ramps up.
struct TimerBox: View {
#Binding var value: Int
#State private var isRunning = false
#State private var startDate: Date? = nil
#State private var timer: Timer? = nil
private static let thresholds = (slow: TimeInterval(0.3), fast: TimeInterval(0.05))
private static let timeToMax = TimeInterval(2.5)
var body: some View {
ZStack {
Rectangle()
.fill(isRunning ? Color.blue : Color.green)
.frame(width: 70, height: 50)
.gesture(DragGesture(minimumDistance: 0)
.onChanged { _ in
self.startRunning()
}
.onEnded { _ in
self.stopRunning()
}
)
Text("\(value)")
.foregroundColor(.white)
}
}
private func startRunning() {
guard isRunning == false else { return }
isRunning = true
startDate = Date()
timer = Timer.scheduledTimer(withTimeInterval: Self.thresholds.slow, repeats: true, block: timerFired)
}
private func timerFired(timer: Timer) {
guard let startDate = self.startDate else { return }
self.value += 1
let timePassed = Date().timeIntervalSince(startDate)
let newSpeed = Self.thresholds.slow - timePassed * (Self.thresholds.slow - Self.thresholds.fast)/Self.timeToMax
let nextFire = Date().advanced(by: max(newSpeed, Self.thresholds.fast))
self.timer?.fireDate = nextFire
}
private func stopRunning() {
timer?.invalidate()
isRunning = false
}
}

Resources