NEW: Learn to build the incredible iOS 15 Weather app today! >>

[SOLVED] How can I make one drag gesture trigger behaviour in multiple views sequentially?

Forums > SwiftUI

In the middle of a view I'm working on for an iPhone app, I have an HStack with a variable number of custom views. How can I make them react when the user drags a finger across them?

To make it more concrete, I'd like each view to play a sound when it's touched. Imagine dragging a finger across a xylophone or piano and each key playing the appropriate sound.

I can make each view respond when tapped pretty easily.

// A bunch of views above the relavant views

// Create a SoundView for each sound the array
HStack {
                ForEach(sounds) { sound in
                    SoundView(sound)
                    .onTapGesture() {
                            if let safeSound = sound.name {
                                soundBox.playSound(safeSound)
                            }
                        }
                }
            }

// A bunch of views below the relevant views

However, taps only work for single sounds, don't activate immediately when a view is touched, and don't work with drags.

I've tried replacing the above tap gesture with a drag gesture such as this one, but it only works for the item that's touched first. If I drag my finger from the first SoundView to the second, it doesn't cancel the first sound and play the second.

@GestureState var touching = false

// Create a SoundView for each sound the array
HStack {
                ForEach(sounds) { sound in
                    SoundView(sound)
                    .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local)
                      .updating($touching, body: { (_, touching, _) in
                        touching = true
                        print(sound.name) // printing is easier to see when it's being activated; end state this would be the soundBox.playSound(soundName)
                    })
                    .onEnded() { _ in
                        print("done")
                    })

This activates when touched, but it does NOT:

  • cease to be active when I move my finger off of the originally touched view
  • trigger the next view if I drag my finger from the first view to the second view

I've tried putting the drag gesture into the SoundView directly rather than have it attached a view in the HStack, but that doesn't work either.

I think ideally I'd put state variables inside the SoundView so that it could change visually as well as play a sound when touched, but at the moment, I just can't get the core drag recognition functionality to work even close to the way I want.

   

I've mostly figured out a way to solve the problem. Posting for feedback as well as future reference for others :)

My solution is based on the HWS Switcharoo video: https://youtu.be/ffV_fYcFoX0

However, what I needed to do was switch things around so that instead of the dragged item responding to what's underneath it, the items underneath responded to the drag.

In the main view, I setup a Bool array that I bound to each individually created SoundView. At some point I'll automatically generate the required amount, but for now, I just manually created as many as I needed.

Following the tutorial, I also created an array of CGRect to be populated later. These are what gave me the coordinates on screen to see if I was dragging to the right spot.

The magic happens in the DragGesture, where the drag coordinates get evaluated against the coordinates in the soundFrames. If there's a match, I take the array index of the matched frame and use that to toggle a Bool at the same index in the soundStates array, which are bound to each SoundView. When the SoundView state is active, it performs the desired behaviours.


struct ContentView: View {
    @State private var soundFrames = [CGRect]()
    @State private var soundStates = [false, false, false]

    let sounds: MultiSound
    let soundBox = SoundBox()

    var body: some View {

        //            This came from https://www.youtube.com/watch?v=ffV_fYcFoX0&t=6175s
        HStack {
            ForEach(0 ..< sounds.unit.count) { index in
                SoundView(active: $soundStates[index], sound: sound[index])
                    .overlay(
                        //            Creating an overlay creates a view that matches the size of the original view
                        GeometryReader { geo in
                            Color.clear
                                .onAppear {
                                    //            Insert that into a state array, and now can access the coordinates of frames
                                    soundFrames.insert((geo.frame(in: .global)), at: 0)
                                }
                        }
                    )
            }
            .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
                        .onChanged({ (value) in

                            //                                If there's a match activate the view by toggling state
                            if let match = soundFrames.firstIndex(where: { $0.contains(value.location) }) {
                                soundStates[match] = true
                            } else {
                                deactivateSounds()
                            }
                        })
                        .onEnded({ (_) in
                            deactivateSounds()
                        })
            )
        }
    }
}

In the SoundView, I did a bit of a hack where I added a .background modifier that runs a function that returns a Color, but in the function, I also play some sound. I added a comment to myself that I should investigate using .onChanged when I start to require iOS 14.

struct SoundView: View {
    @Binding var active: Bool

    let sound: Sound
    let soundBox = SoundBox()

    var body: some View {
        VStack {
            Text(sound.text)
                .padding()
                .font(sound.length == .silent ? .body : .largeTitle)
            if sound.symbol != nil {
                Image(systemName: sound.symbol!)
            }
        }
        .background(combo())
//        For iOS 14
//        .onChange(of: active) { (active) in
//            <#code#>
//        }
    }

    private func combo() -> Color {
        if active == true {
            guard let sound = sound.sound else { return Color.red }

            if sound.length == .long {
                soundBox.playSound(sound, categoy: .beep, loop: true)
            } else if sound.length == .short {
                soundBox.playSound(sound, categoy: .beep)
            }

            return Color.red
        } else {
            soundBox.stopPlayers()
            return Color.clear
        }
    }
}

I'm sure it can be done better, so feedback definitely welcome :)

   

Hacking with Swift is sponsored by Sentry

SPONSORED With Sentry’s error and performance monitoring for iOS, you see mobile vitals that actually matter, can solve any latency issues quickly, and learn how each release is performing over time.

Learn More

Sponsor Hacking with Swift and reach the world's largest Swift community!

Archived topic

This topic has been closed due to inactivity, so you can't reply. Please create a new topic if you need to.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.