From ad1a3d48499c18df27df79f6fd9fe9a2e0a1913b Mon Sep 17 00:00:00 2001 From: lizzie Date: Wed, 1 Apr 2026 07:36:53 +0000 Subject: [PATCH] swiftuijoystick --- src/ios/CMakeLists.txt | 3 + src/ios/EmulationView.swift | 8 +- src/ios/FileManager.swift | 7 +- src/ios/JoystickView.swift | 6 +- src/ios/SwiftUIJoystick.swift | 309 ++++++++++++++++++++++++++++++++++ 5 files changed, 320 insertions(+), 13 deletions(-) create mode 100644 src/ios/SwiftUIJoystick.swift diff --git a/src/ios/CMakeLists.txt b/src/ios/CMakeLists.txt index 2e98dc843f..5065e29f2b 100644 --- a/src/ios/CMakeLists.txt +++ b/src/ios/CMakeLists.txt @@ -29,6 +29,9 @@ add_executable(eden-ios GameButtonView.swift EmulationScreenView.swift GameListView.swift + + # Extensions + SwiftUIJoystick.swift ) set(MACOSX_BUNDLE_GUI_IDENTIFIER "dev.eden-emu.eden") diff --git a/src/ios/EmulationView.swift b/src/ios/EmulationView.swift index 2bb60e77f4..93d0005f6e 100644 --- a/src/ios/EmulationView.swift +++ b/src/ios/EmulationView.swift @@ -488,10 +488,10 @@ struct EmulationView: View { viewModel.customButtonTapped() } .navigationBarBackButtonHidden(true) - .introspect(.tabView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { (tabBarController) in - tabBarController.tabBar.isHidden = true - uiTabBarController = tabBarController - } + // .introspect(.tabView, on: .iOS(.v13, .v14, .v15, .v16, .v17)) { (tabBarController) in + // tabBarController.tabBar.isHidden = true + // uiTabBarController = tabBarController + // } } private func startPollingFirstFrameShowed() { diff --git a/src/ios/FileManager.swift b/src/ios/FileManager.swift index 2074376c77..28bde33200 100644 --- a/src/ios/FileManager.swift +++ b/src/ios/FileManager.swift @@ -23,16 +23,11 @@ struct Core : Comparable, Hashable { let fileManager = FileManager.default let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first! let destinationURL = documentsDirectory.appendingPathComponent("nand/system/Contents/registered") - - if !fileManager.fileExists(atPath: destinationURL.path) { try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) } - - - try Zip.unzipFile(fileURL, destination: destinationURL, overwrite: true, password: nil) + //try Zip.unzipFile(fileURL, destination: destinationURL, overwrite: true, password: nil) print("File unzipped successfully to \(destinationURL.path)") - } catch { print("Failed to unzip file: \(error)") } diff --git a/src/ios/JoystickView.swift b/src/ios/JoystickView.swift index 61d56cedc4..f76d6caa32 100644 --- a/src/ios/JoystickView.swift +++ b/src/ios/JoystickView.swift @@ -19,13 +19,13 @@ public struct Joystick: View { let appui = AppUI.shared - @ObservedObject public var joystickMonitor = JoystickMonitor() + @ObservedObject public var joystickMonitor = SwiftUIJoystick.JoystickMonitor() private let dragDiameter: CGFloat = 160 - private let shape: JoystickShape = .circle + private let shape: SwiftUIJoystick.JoystickShape = .circle public var body: some View { VStack{ - JoystickBuilder( + SwiftUIJoystick.JoystickBuilder( monitor: self.joystickMonitor, width: self.dragDiameter, shape: .circle, diff --git a/src/ios/SwiftUIJoystick.swift b/src/ios/SwiftUIJoystick.swift new file mode 100644 index 0000000000..4f27726bdf --- /dev/null +++ b/src/ios/SwiftUIJoystick.swift @@ -0,0 +1,309 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright 2021 Eden Emulator Project +// SPDX-License-Identifier: MIT + +import SwiftUI + +public protocol PolarCoordinate { + /// The direction the thumb handle is pointing, up is 0° and right is 90° + var degrees: CGFloat { get set } + /// The thumb handle's distance from the center/origin + var distance: CGFloat { get set } +} + +public struct PolarPoint: PolarCoordinate { + /// The direction from origin, up is 0° and right is 90° + public var degrees: CGFloat + /// The distance from center/origin + public var distance: CGFloat + + public static let zero: PolarPoint = PolarPoint(degrees: 0, distance: 0) + + public init(degrees: CGFloat, distance: CGFloat) { + self.degrees = degrees + self.distance = distance + } +} + +/// The type of background shape used for the touch/click hitbox +/// +/// Rect will allow every coordinate to be used by the joystick's thumb position +/// Circle will limit the position output to the circular area defined by the midpoint and diameter/width +public enum JoystickShape { + /// Will allow the cursor to go from 0,0 to 0, width, and width, 0 + case rect + /// Will limit the curser to a circular area surrounding the center of the joystick + /// This cannot reach the corners but can reach min and max for both the x and y axis any edge's midpoint + case circle +} + +public extension CGPoint { + internal static func +(_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { + return CGPoint(x: lhs.x + rhs.x, y: lhs.y + rhs.y) + } + + internal static func -(_ lhs: CGPoint, _ rhs: CGPoint) -> CGPoint { + return CGPoint(x: lhs.x - rhs.x, y: lhs.y - rhs.y) + } + + internal static func *(_ lhs: CGPoint, _ rhs: CGFloat) -> CGPoint { + return CGPoint(x: lhs.x * rhs, y: lhs.y * rhs) + } + + func distance(to point: CGPoint) -> CGFloat { + return sqrt(pow((point.x - x), 2) + pow((point.y - y), 2)) + } + + func getPointOnCircle(radius: CGFloat, radian: CGFloat) -> CGPoint { + let x = self.x + radius * cos(radian) + let y = self.y + radius * sin(radian) + + return CGPoint(x: x, y: y) + } + + func getRadian(pointOnCircle: CGPoint) -> CGFloat { + let originX = pointOnCircle.x - self.x + let originY = pointOnCircle.y - self.y + var radian = atan2(originY, originX) + while radian < 0 { + radian += CGFloat(2 * Double.pi) + } + return radian + } + + func getPolarPoint(from origin: CGPoint = CGPoint.zero) -> PolarPoint { + let deltaX = self.x - origin.x + let deltaY = self.y - origin.y + let radians = -1 * atan2(deltaY, deltaX) + let degrees = radians * (180.0 / CGFloat.pi) + let distance = self.distance(to: origin) + + guard degrees < 0 else { + return PolarPoint(degrees: degrees, distance: distance) + } + return PolarPoint(degrees: degrees + 360.0, distance: distance) + } +} + +public class JoystickMonitor: ObservableObject { + @Published public var xyPoint: CGPoint = .zero + @Published public var polarPoint: PolarPoint = .zero + + public init() { } +} + +/// A convenience SwiftUI ViewModifier to make a view behave like a Joystick +public struct JoystickGestureRecognizer: ViewModifier { + @ObservedObject public var joystickMonitor: JoystickMonitor + /// The size of the control area in which the drag gesture is monitored and reported, is diameter for a circular Joystick + private var width: CGFloat + /// The shape of the hitbox for the position output of the Joystick Thumb position + private var shapeType: JoystickShape + /// The center point of the Joystick where it goes to rest when not being used in `locksInPlace` is false + private let midPoint: CGPoint + /// Determines whether or not the Joystick Thumb control goes back to the center point when released + private let locksInPlace: Bool + @Binding private(set) public var thumbPosition: CGPoint + + /// Creates a custom joystick with the following configuration + /// + /// parameter joystickMonitor: An object used to monitor the valid position of the thumb on the Joystick + /// parameter width: Width of the joystick control area, for a circular Joystick this is the diameter + /// parameter type: Shape of the hitbox for the position output of the Joystick Thumb position + /// parameter background: The view displayed as the Joystick background + /// parameter foreground: The view displayed as the Joystick Thumb Control + /// parameter locksInPlace: Determines if the thumb control returns to the center point when released + public init(thumbPosition: Binding, monitor: JoystickMonitor, width: CGFloat, type: JoystickShape, locksInPlace locks: Bool = false) { + self.joystickMonitor = monitor + self._thumbPosition = thumbPosition + self.width = width + self.midPoint = CGPoint(x: width / 2, y: width / 2) + self.shapeType = type + self.locksInPlace = locks + } + + /// Produce the correct shape of Joystick + public func body(content: Content) -> some View { + switch self.shapeType { + case .rect: + rectBody(content) + case .circle: + circleBody(content) + } + } + + internal func getValidThumbCoordinate(for value: inout CGFloat) { + if value <= 0 { + value = 0 + } else if value > width { + value = self.width + } + } + + internal func validateCoordinate(_ emitPoint: inout CGPoint) { + emitPoint = emitPoint * 2 + if emitPoint.x > width { + emitPoint.x = width + } else if emitPoint.x < -width { + emitPoint.x = -width + } + if emitPoint.y > width { + emitPoint.y = width + } else if emitPoint.y < -width { + emitPoint.y = -width + } + } + + /// Sets the coordinates of the user's thumb to the JoystickMonitor, which emits an object change since it is an observable + internal func emitPosition(for xyPoint: CGPoint) { + var emitPoint = xyPoint + validateCoordinate(&emitPoint) + self.joystickMonitor.xyPoint = emitPoint + self.joystickMonitor.polarPoint = emitPoint.getPolarPoint(from: self.midPoint) + } + + /// Provides a Rectangular area in which the Joystick control can move within and report values for + /// + /// - parameter content: The view for which to apply the Joystick listener/DragGesture + public func rectBody(_ content: Content) -> some View { + content + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged({ value in + var thumbX = value.location.x + var thumbY = value.location.y + self.getValidThumbCoordinate(for: &thumbX) + self.getValidThumbCoordinate(for: &thumbY) + self.thumbPosition = CGPoint(x: thumbX, y: thumbY) + let position = value.location - self.midPoint + self.emitPosition(for: position) + }) + .onEnded({ value in + if !locksInPlace { + self.thumbPosition = self.midPoint + self.emitPosition(for: .zero) + } + }) + .exclusively( + before: + LongPressGesture(minimumDuration: 0.0, maximumDistance: 0.0) + .onEnded({ _ in + if !locksInPlace { + self.thumbPosition = self.midPoint + self.emitPosition(for: .zero) + } + }) + ) + ) + } + + /// Provides a Circular area in which the Joystick control can move within and report values forr + /// + /// - parameter content: The view for which to apply the Joystick listener/DragGesture + public func circleBody(_ content: Content) -> some View { + content + .contentShape(Circle()) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .onChanged() { value in + let distance = self.midPoint.distance(to: value.location) + if distance > self.width / 2 { + // Limit to radius + let k = (self.width / 2) / distance + let position = (value.location - self.midPoint) * k + // Order matters + self.thumbPosition = position + self.midPoint + self.emitPosition(for: position) + } else { + self.thumbPosition = value.location + let position = value.location - self.midPoint + self.emitPosition(for: position) + } + } + .onEnded({ value in + if !locksInPlace { + self.thumbPosition = self.midPoint + self.emitPosition(for: .zero) + } + }) + .exclusively( + before: + LongPressGesture(minimumDuration: 0.0, maximumDistance: 0.0) + .onEnded({ _ in + if !locksInPlace { + self.thumbPosition = self.midPoint + self.emitPosition(for: .zero) + } + }) + ) + ) + } +} + +/// A convenience SwiftUI struct to make a Joystick control +public struct JoystickBuilder: View { + /// The width of the joystick control area, for a circular Joystick this is the diameter + private(set) public var width: CGFloat + /// The shape of the hitbox for the position output of the Joystick Thumb position + private(set) public var controlShape: JoystickShape + + @ObservedObject private(set) public var joystickMonitor: JoystickMonitor + @State private(set) public var thumbPosition: CGPoint = .zero + /// The view displayed as the Joystick background, which also holds a Joystick DragGesture recognizer + @ViewBuilder public var controlBackground: () -> background + /// The view displayed as the Joystick Thumb Control, which also holds a Joystick DragGesture recognizer + @ViewBuilder public var controlThumb: () -> foreground + /// Determines whether or not the Joystick Thumb control goes back to the center point when released + private let locksInPlace: Bool + + /// Creates a custom joystick with two views that are passed to it + /// + /// parameter position: Will output the valid position of the thumb on the Joystick, from 0 to width + /// parameter width: Width of the joystick control area, for a circular Joystick this is the diameter + /// parameter shape: Shape of the hitbox for the position output of the Joystick Thumb position + /// parameter background: The view displayed as the Joystick background + /// parameter foreground: The view displayed as the Joystick Thumb Control + /// parameter locksInPlace: Determines if the thumb control returns to the center point when released + public init(monitor: JoystickMonitor, width: CGFloat, shape: JoystickShape, @ViewBuilder background: @escaping () -> background, @ViewBuilder foreground: @escaping () -> foreground, locksInPlace locks: Bool) { + self.joystickMonitor = monitor + self.width = width + self.controlShape = shape + self.controlBackground = background + self.controlThumb = foreground + self.locksInPlace = locks + } + + public var body: some View { + controlBackground() + .frame(width: self.width, height: self.width) + .joystickGestureRecognizer(thumbPosition: self.$thumbPosition, monitor: self.joystickMonitor, width: self.width, shape: self.controlShape, locksInPlace: self.locksInPlace) + .overlay( + controlThumb() + .frame(width: self.width / 4, height: self.width / 4) + .position(x: self.thumbPosition.x, y: self.thumbPosition.y) + .joystickGestureRecognizer(thumbPosition: self.$thumbPosition, monitor: self.joystickMonitor, width: self.width, shape: self.controlShape, locksInPlace: self.locksInPlace) + ) + .onAppear(perform: { + let midPoint = self.width / 2 + self.thumbPosition = CGPoint(x: midPoint, y: midPoint) + }) + } +} + +public extension View { + /// Convenience modifier for adding a Joystick recognizer + /// Creates a custom joystick with the following configuration + /// + /// parameter joystickMonitor: An object used to monitor the valid position of the thumb on the Joystick + /// parameter width: Width of the joystick control area, for a circular Joystick this is the diameter + /// parameter shape: (.rect || .circle) - Shape of the hitbox for the position output of the Joystick Thumb position + /// parameter background: The view displayed as the Joystick background + /// parameter foreground: The view displayed as the Joystick Thumb Control + /// parameter locksInPlace: default false - Determines if the thumb control returns to the center point when released + /// parameter locksInPlace: default false - Determines if the thumb control returns to the center point when released + func joystickGestureRecognizer(thumbPosition: Binding, monitor: JoystickMonitor, width: CGFloat, shape: JoystickShape, locksInPlace locks: Bool = false) -> some View { + modifier(JoystickGestureRecognizer(thumbPosition: thumbPosition, monitor: monitor, width: width, type: shape, locksInPlace: locks)) + } +}