TokamakUI / Tokamak

SwiftUI-compatible framework for building browser apps with WebAssembly and native apps for other platforms
Apache License 2.0
2.62k stars 111 forks source link

Add gestures initial functionality #538

Open shial4 opened 1 year ago

shial4 commented 1 year ago

Pull Request Description: Add Gestures Functionality

Description

This pull request aims to enhance the Tokamak framework by adding support for handling gestures in SwiftUI-like components. The goal is to enable developers to implement various interactive features in their applications using gestures, such as taps, long presses and drags.

Changes Made

The following changes have been made to the Tokamak framework to support gesture functionality:

  1. Added support for gestures such as TapGesture, LongPressGesture and DragGesture. These gestures are now functional and update the respective state when triggered.

  2. Added helper View modifiers for TapGesture and LongPressGesture aiming to match SwiftUI counterpart.

  3. Implemented TokamakDOM renderer gestures par

  4. Add basic implementation for standard, simultaneous and highPriority gesture handling, without the use of GestureMask.

  5. Add coordinate space

Remaining Work

The following features are yet to be implemented:

  1. Add AnyGesture for type erasure: A generic AnyGesture type is intended to be added to perform type erasure for gestures, allowing for a more flexible and unified approach when handling gestures. WIP, to be fixed.

  2. Add handling of GestureMask.

Example Use Case

To demonstrate the implemented gesture functionality, a sample use case has been provided. It includes examples of tap gestures, double tap gestures, long press gestures, and drag gestures. Certain parts of the code related to simultaneous gesture handling have been commented out, awaiting the implementation of gesture priority and simultaneous gesture support.

Please review and test the changes to ensure they work as expected and are aligned with the Tokamak framework's design and guidelines. Once reviewed and approved, this pull request can be merged into the main repository to enable gesture support in Tokamak.

https://github.com/TokamakUI/Tokamak/assets/8544773/e9e46205-68b9-4a81-b55a-f40fab774fa6

https://github.com/TokamakUI/Tokamak/assets/8544773/b28950ce-da0a-47d4-8c02-67cf8f8c964e

TestCode

The below code has been tested in SwiftUI and Tokamak to ensure the behavior is the same.

import TokamakDOM
import Foundation

@main
struct TokamakApp: App {
    var body: some Scene {
        WindowGroup("Tokamak App") {
            ContentView()
        }
    }
}

struct ContentView: View {
    @State var count: Int = 0
    @State var countDouble: Int = 0
    @GestureState var isDetectingTap = false

    @GestureState var isDetectingLongPress = false
    @State var completedLongPress = false
    @State var countLongpress: Int = 0

    @GestureState var dragAmount = CGSize.zero
    @State private var countDragLongPress = 0

    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            tapGestures
            longPressGestures
            dragGestures
        }
        .padding()
    }

    var dragGestures: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Drag Gestures")

            HStack {
                Rectangle()
                    .fill(Color.yellow)
                    .frame(width: 100, height: 100)
                    .gesture(DragGesture().updating($dragAmount) { value, state, transaction in
                        state = value.translation
                    }.onEnded { value in
                        print(value)
                    })
                Text("dragAmount: \(dragAmount.width), \(dragAmount.height)")
            }

            HStack {
                Rectangle()
                    .fill(Color.red)
                    .frame(width: 100, height: 100)
                    .gesture(DragGesture(minimumDistance: 0)
                        .onChanged { _ in
                            self.countDragLongPress += 1
                        })
                Text("Drag Count: \(countDragLongPress)")
            }
        }
    }

    var longPressGestures: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("LongPress Gestures")

            HStack {
                Rectangle()
                    .fill(self.isDetectingLongPress ? Color.pink : (self.completedLongPress ? Color.purple : Color.gray))
                    .frame(width: 100, height: 100)
                    .gesture(LongPressGesture(minimumDuration: 2)
                        .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                            gestureState = currentState
                            transaction.animation = Animation.easeIn(duration: 2.0)
                        }
                        .onEnded { finished in
                            self.completedLongPress = finished
                        })
                Text(self.isDetectingLongPress ? "detecting" : (self.completedLongPress ? "completed" : "unknow"))
            }

            HStack {
                Rectangle()
                    .fill(Color.orange)
                    .frame(width: 100, height: 100)
                    .onLongPressGesture(minimumDuration: 0) {
                        countLongpress += 1
                    }
                    .onTapGesture() {
                        fatalError("onTapGesture, should not be called")
                    }
                Text("Long Pressed: \(countLongpress)")
            }
        }
    }

    var tapGestures: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Tap Gestures")
            HStack {
                Rectangle()
                    .fill(Color.white)
                    .frame(width: 100, height: 100)
                    .onTapGesture {
                        count += 1
                        print("βšͺ️ gesture")
                    }
                Text("Tap: \(count)")
            }
            HStack {
                Rectangle()
                    .fill(Color.green)
                    .frame(width: 100, height: 100)
                    .onTapGesture(count: 2) {
                        countDouble += 1
                        print("🟒 double gesture")
                    }
                Text("double tap: \(countDouble)")
            }
            HStack {
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: 100, height: 100)
                    .onTapGesture() {
                        print("πŸ”΅ 1st gesture")
                    }
                    .onTapGesture() {
                        fatalError("should not be called")
                    }
                Text("1st tap gesture")
            }
            HStack {
                Rectangle()
                    .fill(Color.pink)
                    .frame(width: 100, height: 100)
                    .simultaneousGesture(
                        TapGesture()
                            .onEnded({ _ in
                                print("🩷 simultaneousGesture gesture")
                            })
                    )
                    .onTapGesture() {
                        fatalError("should not be called")
                    }
                    .onTapGesture() {
                        fatalError("should not be called")
                    }
                    .simultaneousGesture(
                        TapGesture()
                            .onEnded({ _ in
                                print("🩷 simultaneousGesture 2 gesture")
                            })
                    )
                Text("simultaneousGesture")
            }
            HStack {
                Rectangle()
                    .fill(Color.purple)
                    .frame(width: 100, height: 100)
                    .simultaneousGesture(
                        TapGesture()
                            .onEnded({ _ in
                                fatalError("should not be called")
                            })
                    )
                    .onTapGesture() {
                        fatalError("should not be called")
                    }
                    .highPriorityGesture(
                        TapGesture()
                            .onEnded({ _ in
                                fatalError("should not be called")
                            })
                    )
                    .highPriorityGesture(
                        TapGesture()
                            .onEnded({ _ in
                                print("🟣 highPriorityGesture 3 gesture")
                            })
                    )
                Text("highPriorityGesture")
            }
        }
    }
}

Basic implementation of standard, simultaneous and highPriority gesture handling has been tested and compared against SwiftUI playgrounds with the below code

var tapGestures: some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Tap Gestures")

            HStack {
                Rectangle()
                    .fill(Color.blue)
                    .frame(width: 100, height: 100)
                    .simultaneousGesture(
                        TapGesture().onEnded({ _ in
                            print("πŸ”΅ simultaneousGesture gesture")
                        })
                    )
                    .onTapGesture() {
                        print("πŸ”΅ 1st gesture")
                    }
                    .onTapGesture() {
                        print("πŸ”΅ 2st gesture")
                    }
                    .simultaneousGesture(
                        TapGesture().onEnded({ _ in
                            print("πŸ”΅ simultaneousGesture 2 gesture")
                        })
                    )
                Text("simultaneousGesture")
            }
            HStack {
                Rectangle()
                    .fill(Color.purple)
                    .frame(width: 100, height: 100)
                    .simultaneousGesture(
                        TapGesture().onEnded({ _ in
                            print("🟒 simultaneousGesture gesture")
                        })
                    )
                    .onTapGesture() {
                        print("🟒 1st gesture")
                    }
                    .highPriorityGesture(
                        TapGesture().onEnded({ _ in
                            print("🟒 highPriorityGesture 2 gesture")
                        })
                    )
                    .highPriorityGesture(
                        TapGesture().onEnded({ _ in
                            print("🟒 highPriorityGesture 3 gesture")
                        })
                    )
                Text("highPriorityGesture")
            }
        }
    }

output

🟒 highPriorityGesture 3 gesture πŸ”΅ simultaneousGesture 2 gesture πŸ”΅ simultaneousGesture gesture

The main difference in simultaneousGesture is that Tokamak has different order where simultaneousGesture 2 is after simultaneousGesture

https://github.com/TokamakUI/Tokamak/assets/8544773/b1d17188-24c3-4732-84ed-4162afc520f3

With follow-up test

Rectangle()
 .fill(Color.blue)
 .frame(width: 100, height: 100)
.onTapGesture() {
    print("πŸ”΅ 1st gesture")
}
.onTapGesture() {
    print("πŸ”΅ 2st gesture")
}

πŸ”΅ 1st gesture

https://github.com/TokamakUI/Tokamak/assets/8544773/6ce32da4-36a5-47e6-bb2c-34731941e752

Added Fibre support for gestures

https://github.com/TokamakUI/Tokamak/assets/8544773/330f023d-553b-494c-9332-8e1e0162b77a

Tested with the below code

struct GestureDemo: View {
    let rows = 16
    let columns = 16

    struct Rect: Hashable {
        let row: Int
        let column: Int
    }

    @State private var selectedRects: Set<Rect> = []

    var body: some View {
        VStack(spacing: 0) {
            ForEach(0..<rows, id: \.self) { row in
                HStack(spacing: 0) {
                    ForEach(0..<columns, id: \.self) { column in
                        Rectangle()
                            .fill(isSelected(row: row, column: column) ? Color.blue : Color.gray)
                            .frame(width: 50, height: 50)
                            .overlay(
                                Text("\(row):\(column)")
                                    .foregroundColor(.white)
                            )
                    }
                }
            }
        }
        .border(Color.red)
        .coordinateSpace(name: "MyView")
        .gesture(
            TapGesture()
                .onEnded {
                    print("πŸ”΅")
                    selectedRects.removeAll()
                }
        )
        .gesture(
            DragGesture(coordinateSpace: .named("MyView"))
                .onChanged { value in
                    let location = value.location
                    let row = Int(location.y / 50)
                    let column = Int(location.x / 50)
                    print("🟒", row, column, location)
                    if !isSelected(row: row, column: column) {
                        selectedRects.insert(Rect(row: row, column: column))
                    }
                }
        )
    }

    func isSelected(row: Int, column: Int) -> Bool {
        selectedRects.contains(Rect(row: row, column: column))
    }
}
j-f1 commented 1 year ago

This is incredible! I am unfortunately about to hop on a bus to start a job at Apple so I can’t give this a thorough review πŸ₯²

But hopefully @carson-katri can take a look at some point.

shial4 commented 1 year ago

updated PR & its description

bump up

πŸš€

shial4 commented 1 year ago

fixed https://github.com/TokamakUI/Tokamak/issues/545

shial4 commented 1 year ago

coordinate space broken for custom spaces while using fiber reconciler. ticket here: https://github.com/TokamakUI/Tokamak/issues/547

shial4 commented 1 year ago

Add content shape modifier. Verify gesture start against the shape fill area. https://github.com/TokamakUI/Tokamak/issues/548

shial4 commented 1 year ago

updated view change modifier, not to relay on _onChange or _onUnmount events due to this comment https://github.com/TokamakUI/Tokamak/pull/542#issuecomment-1709373845

@carson-katri ready for the review too :)

shial4 commented 1 year ago

Tests for onChange & onReceive will pass after Fiber fixes attempt 2 will be merge

aehlke commented 1 year ago

is this in a good state to use? (sorry to bother)

shial4 commented 1 year ago

@aehlke

is this in a good state to use? (sorry to bother)

I'm building my apps of top of this branch. However would love to have fibre working with gesture, but for that we will need the fibre PR merged in + some other additions and improvements