From 91568e7086369174700a7d5f44060f5454fb152d Mon Sep 17 00:00:00 2001 From: timbittner Date: Fri, 17 Jan 2025 13:46:07 +0100 Subject: [PATCH] Add swipe gestures to Ank-Mode. Swiping on the question background marks answers. Left: incorrect, Right: correct, Down: skip (if skipping allowed), Up: show details (incorrect) This replaces the swipe gestures globally (for unified UX) for changing the font with tap gestures. Taps work like music controls with AirPods single=toggle font (unchanged behavior), double=next font, triple=previous font. Tapping the answer bar will act like tapping the question label previously, i.e. submitting (incorrect) and showing details. Animated banners provide feedback to the user about their swiping direction and intention. Swiping is disabled by default and configurable as sub-setting for Anki-Mode (like the group reading and meaning option). --- ios/AnswerTextField.swift | 15 + ios/ReviewSettingsViewController.swift | 16 ++ ios/ReviewViewController.swift | 146 +++++++--- ios/Settings.swift | 2 + ios/Style.swift | 8 + ios/SwipeableContainer.swift | 347 ++++++++++++++++++++++++ ios/Tsurukame.xcodeproj/project.pbxproj | 4 + 7 files changed, 502 insertions(+), 36 deletions(-) create mode 100644 ios/SwipeableContainer.swift diff --git a/ios/AnswerTextField.swift b/ios/AnswerTextField.swift index a7cb1e8e..da5aba5d 100644 --- a/ios/AnswerTextField.swift +++ b/ios/AnswerTextField.swift @@ -53,4 +53,19 @@ class AnswerTextField: UITextField { } return super.textInputMode } + + // Add a property to toggle interaction. Doing this will allow the textfield to process events, + // which is impossible when simply set to disabled. + var isInteractive: Bool = true { + didSet { + // Dismiss the keyboard if the field is no longer interactive + if !isInteractive && isFirstResponder { + resignFirstResponder() + } + } + } + + override func becomeFirstResponder() -> Bool { + isInteractive ? super.becomeFirstResponder() : false + } } diff --git a/ios/ReviewSettingsViewController.swift b/ios/ReviewSettingsViewController.swift index 4d687170..db3c531f 100644 --- a/ios/ReviewSettingsViewController.swift +++ b/ios/ReviewSettingsViewController.swift @@ -185,6 +185,18 @@ class ReviewSettingsViewController: UITableViewController, TKMViewController { ankiModeCombineReadingMeaningIndexPath = model.add(ankiModeCombineReadingMeaning, hidden:!Settings.ankiMode) + let ankiModeSwipeGestures = SwitchModelItem(style: .subtitle, + title: "Enable swipe gestures", + subtitle: "Use swipes for marking reviews with Anki mode enabled", + on: Settings + .ankiModeEnableSwipeGestures) { [ + unowned self + ] in + ankiModeSwipeGesturesSwitchChanged($0) + } + ankiModeCombineReadingMeaningIndexPath = model.add(ankiModeSwipeGestures, + hidden:!Settings.ankiMode) + model.add(section: "Audio") model.add(SwitchModelItem(style: .subtitle, title: "Play audio automatically", @@ -354,6 +366,10 @@ class ReviewSettingsViewController: UITableViewController, TKMViewController { Settings.ankiModeCombineReadingMeaning = switchView.isOn } + private func ankiModeSwipeGesturesSwitchChanged(_ switchView: UISwitch) { + Settings.ankiModeEnableSwipeGestures = switchView.isOn + } + private func playAudioAutomaticallySwitchChanged(_ switchView: UISwitch) { Settings.playAudioAutomatically = switchView.isOn } diff --git a/ios/ReviewViewController.swift b/ios/ReviewViewController.swift index de9254d5..cfa176b3 100644 --- a/ios/ReviewViewController.swift +++ b/ios/ReviewViewController.swift @@ -159,7 +159,8 @@ protocol ReviewViewControllerDelegate: AnyObject { menuButton: UIButton) } -class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelegate { +class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelegate, + SwipeableContainerDelegate { private var kanaInput: TKMKanaInput! private let hapticGenerator = UIImpactFeedbackGenerator(style: UIImpactFeedbackGenerator .FeedbackStyle.light) @@ -185,6 +186,8 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega private var isPracticeSession = false + private var swipeContainer: SwipeableContainer! + // These are set to match the keyboard animation. private var animationDuration: Double = kDefaultAnimationDuration private var animationCurve: UIView.AnimationCurve = kDefaultAnimationCurve @@ -302,21 +305,40 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega questionLabel.isUserInteractionEnabled = false - let shortPressRecognizer = - UITapGestureRecognizer(target: self, action: #selector(didShortPressQuestionLabel)) - questionBackground.addGestureRecognizer(shortPressRecognizer) + // set up swipe gestures for answering + swipeContainer = SwipeableContainer() + swipeContainer.delegate = self + swipeContainer.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(swipeContainer, belowSubview: promptBackground) + + // Add constraints to match question background + NSLayoutConstraint.activate([ + swipeContainer.leadingAnchor.constraint(equalTo: questionBackground.leadingAnchor), + swipeContainer.trailingAnchor.constraint(equalTo: questionBackground.trailingAnchor), + swipeContainer.topAnchor.constraint(equalTo: questionBackground.topAnchor), + swipeContainer.bottomAnchor.constraint(equalTo: questionBackground.bottomAnchor), + ]) + + // font cycling tap recognizers + func setupTapQuestionRecognizer(numberOfTapsRequired: Int = 1) -> UITapGestureRecognizer { + let tapRecognizer = UITapGestureRecognizer(target: self, + action: #selector(didTapQuestionView)) + swipeContainer.addGestureRecognizer(tapRecognizer) + tapRecognizer.numberOfTapsRequired = numberOfTapsRequired + return tapRecognizer + } + + let singleTapRecognizer = setupTapQuestionRecognizer() + let doubleTapRecognizer = setupTapQuestionRecognizer(numberOfTapsRequired: 2) + let tripleTapRecognizer = setupTapQuestionRecognizer(numberOfTapsRequired: 3) - let leftSwipeRecognizer = UISwipeGestureRecognizer(target: self, - action: #selector(didSwipeQuestionLabel)) - leftSwipeRecognizer.direction = .left - questionBackground.addGestureRecognizer(leftSwipeRecognizer) - let rightSwipeRecognizer = UISwipeGestureRecognizer(target: self, - action: #selector(didSwipeQuestionLabel)) - rightSwipeRecognizer.direction = .right - questionBackground.addGestureRecognizer(rightSwipeRecognizer) + // make sure to fail the tap gesture recognizers with less taps + singleTapRecognizer.require(toFail: doubleTapRecognizer) + doubleTapRecognizer.require(toFail: tripleTapRecognizer) - leftSwipeRecognizer.require(toFail: shortPressRecognizer) - rightSwipeRecognizer.require(toFail: shortPressRecognizer) + // add a tap gesture for the answer bar for submitting in anki mode + let tapRecognizer = UITapGestureRecognizer(target: self, action: #selector(didTapAnswerBar)) + answerField.addGestureRecognizer(tapRecognizer) resizeViewsForFontSize() viewDidLayoutSubviews() @@ -728,6 +750,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega submitButton.isHidden = false } } + updateSwipeConfiguration() // Change the submit button icon. let submitButtonImage = shown ? forwardArrowImage : @@ -760,7 +783,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega // Enable/disable the answer field, and set its first responder status. // This makes the keyboard appear or disappear immediately. We need this animation to happen // here so it's in sync with the others. - answerField.isEnabled = !shown && !Settings.ankiMode + answerField.isInteractive = !shown && !Settings.ankiMode if updateFirstResponder { if !shown { answerField.becomeFirstResponder() @@ -814,6 +837,8 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega } } + updateSwipeConfiguration() + // This makes sure taps are still processed and not ignored, even when the closing animation // after a button press was not completed if Settings.ankiMode, ankiModeCachedSubmit { submit() } @@ -896,19 +921,12 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega questionLabel.font = UIFont(name: fontName, size: questionLabelFontSize()) } - @objc func didShortPressQuestionLabel(_: UITapGestureRecognizer) { - toggleFont() - if Settings.ankiMode { - if !isAnimatingSubjectDetailsView { submit() } - else { ankiModeCachedSubmit = true } - } - } - - @objc func didSwipeQuestionLabel(_ sender: UISwipeGestureRecognizer) { - if sender.direction == .left { - showNextCustomFont() - } else if sender.direction == .right { - showPreviousCustomFont() + @objc func didTapQuestionView(_ sender: UITapGestureRecognizer) { + switch sender.numberOfTapsRequired { + case 1: toggleFont() + case 2: showNextCustomFont() + case 3: showPreviousCustomFont() + default: break } } @@ -944,6 +962,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega } func quickSettingsChanged() { + updateSwipeConfiguration() if subjectDetailsView.isHidden { updateViewForCurrentTask(updateFirstResponder: false) } @@ -1006,7 +1025,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega markAnswer(.AskAgainLater) return } - if !answerField.isEnabled, !Settings.ankiMode { + if !answerField.isInteractive, !Settings.ankiMode { if !subjectDetailsView.isHidden { subjectDetailsView.saveStudyMaterials() } @@ -1020,7 +1039,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega @objc func backspaceKeyPressed() { answerField.text = nil answerField.textColor = TKMStyle.Color.label - answerField.isEnabled = true + answerField.isInteractive = true answerField.becomeFirstResponder() } @@ -1144,7 +1163,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega UIView.animate(withDuration: animationDuration, animations: { self.answerField.textColor = .systemRed - self.answerField.isEnabled = false + self.answerField.isInteractive = false self.revealAnswerButton.alpha = 1.0 self.submitButton.setImage(self.forwardArrowImage, for: .normal) }) @@ -1170,6 +1189,13 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega updateFirstResponder: true) } + @objc func didTapAnswerBar(_: UITapGestureRecognizer) { + if Settings.ankiMode { + if !isAnimatingSubjectDetailsView { submit() } + else { ankiModeCachedSubmit = true } + } + } + // MARK: - Ignoring incorrect answers @IBAction func addSynonymButtonPressed(_: Any) { @@ -1183,7 +1209,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega c.addAction(UIAlertAction(title: "My answer was correct", style: .default, - handler: { _ in self.markCorrect() })) + handler: { _ in self.markOverrideCorrect() })) if Settings.ankiMode { c.addAction(UIAlertAction(title: "My answer was incorrect", style: .default, @@ -1203,7 +1229,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega present(c, animated: true, completion: nil) } - @objc func markCorrect() { + @objc func markOverrideCorrect() { markAnswer(.OverrideAnswerCorrect) } @@ -1229,6 +1255,54 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega super.canPerformAction(action, withSender: sender) } + // MARK: - Swipe Gesture Delegate + + func containerDidSwipeRight(_: SwipeableContainer) { + // Handle correct answer + if !subjectDetailsView.isHidden { + // call the same function that is used by the synonyms menu to mark correct + markOverrideCorrect() + } else { + // use the marking function for outside the details view + markAnswer(.Correct) + } + } + + func containerDidSwipeLeft(_: SwipeableContainer) { + if subjectDetailsView.isHidden { + _ = session.markAnswer(.Incorrect, isPracticeSession: isPracticeSession) + } + randomTask() + } + + func containerDidSwipeDown(_: SwipeableContainer) { + // Skip question + if Settings.allowSkippingReviews { + markAnswer(.AskAgainLater) + return + } + } + + func containerDidSwipeUp(_: SwipeableContainer) { + submit() + } + + private func updateSwipeConfiguration() { + var config = SwipeableContainer.SwipeConfiguration() + + guard Settings.ankiMode, + Settings.ankiModeEnableSwipeGestures else { + swipeContainer.updateSwipeConfiguration(config) + return + } + + config.isRightEnabled = true + config.isLeftEnabled = true + config.isDownEnabled = Settings.allowSkippingReviews + config.isUpEnabled = subjectDetailsView.isHidden + swipeContainer.updateSwipeConfiguration(config) + } + // MARK: - SubjectDelegate func didTapSubject(_ subject: TKMSubject) { @@ -1252,7 +1326,7 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega discoverabilityTitle: "Continue") var keyCommands: [UIKeyCommand] = [] - if !answerField.isEnabled, subjectDetailsView.isHidden { + if !answerField.isInteractive, subjectDetailsView.isHidden { // Continue when a wrong answer has been entered but the subject details view is hidden. keyCommands.append(contentsOf: [UIKeyCommand(input: "\u{8}", modifierFlags: [], @@ -1276,11 +1350,11 @@ class ReviewViewController: UIViewController, UITextFieldDelegate, SubjectDelega discoverabilityTitle: "Ask again later"), UIKeyCommand(input: "c", modifierFlags: [.command], - action: #selector(markCorrect), + action: #selector(markOverrideCorrect), discoverabilityTitle: "Mark correct"), UIKeyCommand(input: "c", modifierFlags: [.control], - action: #selector(markCorrect)), + action: #selector(markOverrideCorrect)), UIKeyCommand(input: "i", modifierFlags: [.command], action: #selector(markIncorrect), diff --git a/ios/Settings.swift b/ios/Settings.swift index 639d6572..3848cbf7 100644 --- a/ios/Settings.swift +++ b/ios/Settings.swift @@ -203,6 +203,8 @@ protocol SettingProtocol { @Setting(false, #keyPath(ankiMode)) static var ankiMode: Bool @Setting(false, #keyPath(ankiModeCombineReadingMeaning)) static var ankiModeCombineReadingMeaning: Bool + @Setting(false, + #keyPath(ankiModeEnableSwipeGestures)) static var ankiModeEnableSwipeGestures: Bool @Setting(true, #keyPath(showPreviousLevelGraph)) static var showPreviousLevelGraph: Bool @Setting(true, #keyPath(showKanaOnlyVocab)) static var showKanaOnlyVocab: Bool diff --git a/ios/Style.swift b/ios/Style.swift index 7f593378..1c906338 100644 --- a/ios/Style.swift +++ b/ios/Style.swift @@ -77,6 +77,14 @@ class TKMStyle: NSObject { static let explosionColor2 = UIColor(red: 230.0 / 255, green: 57.0 / 255, blue: 91.0 / 255, alpha: 1.0) + static let incorrectAnswerColor: UIColor = .init(red: 234.0 / 255, green: 51.0 / 255, + blue: 61.0 / 255, + alpha: 1.0) // also srsLevelDownColor + static let correctAnswerColor: UIColor = .init(red: 151.0 / 255, green: 202.0 / 255, + blue: 62.0 / 255, alpha: 1.0) + static let srsLevelUpColor: UIColor = .init(red: 91.0 / 255, green: 168.0 / 255, blue: 51.0 / 255, + alpha: 1.0) + static var radicalGradient: [CGColor] { [radicalColor1.cgColor, radicalColor2.cgColor] } static var kanjiGradient: [CGColor] { [kanjiColor1.cgColor, kanjiColor2.cgColor] } static var vocabularyGradient: [CGColor] { [vocabularyColor1.cgColor, vocabularyColor2.cgColor] } diff --git a/ios/SwipeableContainer.swift b/ios/SwipeableContainer.swift new file mode 100644 index 00000000..2ac080d9 --- /dev/null +++ b/ios/SwipeableContainer.swift @@ -0,0 +1,347 @@ +// Copyright 2025 David Sansome +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import UIKit + +class SwipeableContainer: UIView { + weak var delegate: SwipeableContainerDelegate? + + private(set) var swipeConfiguration: SwipeConfiguration + + private let leftBanner: UIView + private let rightBanner: UIView + private let topBanner: UIView + private let bottomBanner: UIView + + private var initialPanPoint: CGPoint = .zero + private var currentSwipeDirection: SwipeDirection? + private var originalGradientColors: [CGColor] = [] + + // Configuration + private let swipeThreshold: CGFloat = 200 // around 4cm depending on device + private let angleThreshold: CGFloat = .pi / 8 // 22.5 degrees for diagonal detection + private let kDefaultAnimationDuration: TimeInterval = + 0.25 // same as review view controller, maybe pass this around + + override init(frame: CGRect) { + swipeConfiguration = .allDisabled + leftBanner = UIView() + rightBanner = UIView() + topBanner = UIView() + bottomBanner = UIView() + + super.init(frame: frame) + + setupBanners() + setupGestureRecognizer() + isUserInteractionEnabled = true + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupBanners() { + leftBanner.backgroundColor = TKMStyle.correctAnswerColor + rightBanner.backgroundColor = TKMStyle.incorrectAnswerColor + topBanner.backgroundColor = TKMStyle.Color.grey33 + bottomBanner.backgroundColor = TKMStyle.Color.grey80 + + leftBanner.isHidden = true + rightBanner.isHidden = true + topBanner.isHidden = true + bottomBanner.isHidden = true + + updateBannerFrames() + + addSubview(leftBanner) + addSubview(rightBanner) + addSubview(topBanner) + addSubview(bottomBanner) + + // Add skip icon to top banner + let skipIcon = UIImageView(image: Asset.goforwardPlus.image) + skipIcon.tintColor = .white + skipIcon.translatesAutoresizingMaskIntoConstraints = false + topBanner.addSubview(skipIcon) + + NSLayoutConstraint.activate([ + skipIcon.centerXAnchor.constraint(equalTo: topBanner.centerXAnchor), + skipIcon.bottomAnchor.constraint(equalTo: topBanner.bottomAnchor, constant: -48), + skipIcon.widthAnchor.constraint(equalToConstant: 24), + skipIcon.heightAnchor.constraint(equalToConstant: 24), + ]) + + // Add "Show Answer" label to bottom banner + let showAnswerLabel = UILabel() + showAnswerLabel.text = "Show Answer" + showAnswerLabel.textColor = .white + showAnswerLabel.font = .systemFont(ofSize: 17, weight: .medium) + showAnswerLabel.translatesAutoresizingMaskIntoConstraints = false + bottomBanner.addSubview(showAnswerLabel) + + NSLayoutConstraint.activate([ + showAnswerLabel.centerXAnchor.constraint(equalTo: bottomBanner.centerXAnchor), + showAnswerLabel.topAnchor.constraint(equalTo: bottomBanner.topAnchor, constant: 48), + ]) + } + + private func updateBannerFrames() { + leftBanner.frame = CGRect(x: -bounds.width, y: 0, + width: bounds.width, height: bounds.height) + rightBanner.frame = CGRect(x: bounds.width, y: 0, + width: bounds.width, height: bounds.height) + topBanner.frame = CGRect(x: 0, y: -bounds.height, + width: bounds.width, height: bounds.height) + bottomBanner.frame = CGRect(x: 0, y: bounds.height, + width: bounds.width, height: bounds.height) + } + + override func layoutSubviews() { + super.layoutSubviews() + updateBannerFrames() + } + + private func setupGestureRecognizer() { + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + panGesture.delegate = self + addGestureRecognizer(panGesture) + } + + // MARK: - Swipe Logic + + // Configuration for enabling/disabling swipe directions + struct SwipeConfiguration { + var isRightEnabled: Bool = false + var isLeftEnabled: Bool = false + var isDownEnabled: Bool = false + var isUpEnabled: Bool = false + + static var allEnabled: SwipeConfiguration { + SwipeConfiguration(isRightEnabled: true, isLeftEnabled: true, isDownEnabled: true, + isUpEnabled: true) + } + + static var allDisabled: SwipeConfiguration { + SwipeConfiguration() + } + } + + private enum SwipeDirection { + case left, right, down, up + + static func determineDirection(from translation: CGPoint, + angleThreshold: CGFloat) -> SwipeDirection? { + // Add minimum threshold to avoid detecting tiny movements + let minimumMovement: CGFloat = 10 + if abs(translation.x) < minimumMovement && abs(translation.y) < minimumMovement { + return nil + } + + // Check if movement is too diagonal + let angle = atan2(translation.y, translation.x) + let isInDeadZone = abs(abs(angle) - .pi / 4) < angleThreshold + if isInDeadZone { + return nil + } + + let isVertical = abs(translation.y) > abs(translation.x) + + if isVertical { + return translation.y > 0 ? .down : .up + } else { + return translation.x > 0 ? .right : .left + } + } + } + + func updateSwipeConfiguration(_ configuration: SwipeConfiguration) { + swipeConfiguration = configuration + } + + private func isDirectionEnabled(_ direction: SwipeDirection) -> Bool { + switch direction { + case .right: + return swipeConfiguration.isRightEnabled + case .left: + return swipeConfiguration.isLeftEnabled + case .down: + return swipeConfiguration.isDownEnabled + case .up: + return swipeConfiguration.isUpEnabled + } + } + + private func isSwipeSignificant(direction: SwipeDirection, + translation: CGPoint, + velocity: CGPoint) -> Bool { + let minimumDistance: CGFloat = 20 + let velocityThreshold: CGFloat = 1000 + let velocitySlack: CGFloat = 50 // Allow some tolerance for near-zero velocities + + // Extract relevant axis value based on direction + let (axisTranslation, axisVelocity) = switch direction { + case .up, .down: + (translation.y, velocity.y) + case .left, .right: + (translation.x, velocity.x) + } + + // Both translation and velocity should match intended direction + let matchesDirection = switch direction { + case .down, .right: axisTranslation > 0 && (axisVelocity > -velocitySlack) + case .up, .left: axisTranslation < 0 && (axisVelocity < velocitySlack) + } + + return matchesDirection && (abs(axisTranslation) > swipeThreshold || + (abs(axisVelocity) > velocityThreshold && + abs(axisTranslation) >= minimumDistance)) + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: self) + + switch gesture.state { + case .began: + initialPanPoint = gesture.location(in: self) + currentSwipeDirection = SwipeDirection.determineDirection(from: translation, + angleThreshold: angleThreshold) + + // Only show the banners that are enabled by the configuration + leftBanner.isHidden = !swipeConfiguration.isLeftEnabled + rightBanner.isHidden = !swipeConfiguration.isRightEnabled + topBanner.isHidden = !swipeConfiguration.isDownEnabled + bottomBanner.isHidden = !swipeConfiguration.isUpEnabled + + case .changed: + + // keep checking until we have a direction + if currentSwipeDirection == nil { + currentSwipeDirection = SwipeDirection.determineDirection(from: translation, + angleThreshold: angleThreshold) + } + + guard let direction = currentSwipeDirection, + isDirectionEnabled(direction) else { + return + } + + // Animate banners + UIView.animate(withDuration: 0.1) { + switch direction { + case .down: + let maxDistance = min(self.bounds.height, 150) + let clampedTranslation = max(0, min(translation.y, maxDistance)) + self.topBanner.frame.origin.y = -self.bounds.height + clampedTranslation + case .up: + let maxDistance = min(self.bounds.height, 150) + let clampedTranslation = max(-maxDistance, min(0, translation.y)) + self.bottomBanner.frame.origin.y = self.bounds.height + clampedTranslation + case .right: + self.leftBanner.frame.origin.x = -self.bounds.width + (translation.x) + case .left: + self.rightBanner.frame.origin.x = self.bounds.width + (translation.x) + } + } + + case .ended: + let velocity = gesture.velocity(in: self) + guard let direction = currentSwipeDirection else { + resetBanners() + return + } + + if isSwipeSignificant(direction: direction, translation: translation, velocity: velocity) && + isDirectionEnabled(direction) { + animateSwipeCompletion(in: direction) + } else { + resetBanners() + } + + default: + resetBanners() + } + } + + // MARK: - Animation + + private func animateSwipeCompletion(in direction: SwipeDirection) { + let targetBanner: UIView! + switch direction { + case .right: targetBanner = leftBanner + case .left: targetBanner = rightBanner + case .down: targetBanner = topBanner + case .up: targetBanner = bottomBanner + } + + UIView.animate(withDuration: kDefaultAnimationDuration, animations: { + // First animation: fill screen + targetBanner.frame = CGRect(x: 0, y: 0, width: self.bounds.width, height: self.bounds.height) + + }) { _ in + // Second animation: Fade out banner + UIView.animate(withDuration: self.kDefaultAnimationDuration, + delay: 0.1, + options: .curveEaseOut, animations: { + targetBanner.alpha = 0 + }) { _ in + // Notify delegate + switch direction { + case .right: self.delegate?.containerDidSwipeRight(self) + case .left: self.delegate?.containerDidSwipeLeft(self) + case .down: self.delegate?.containerDidSwipeDown(self) + case .up: self.delegate?.containerDidSwipeUp(self) + } + + self.resetBanners() + } + } + } + + private func resetBanners() { + // Slides the banners back to their original position and hides them + UIView.animate(withDuration: kDefaultAnimationDuration, animations: { + self.updateBannerFrames() + }) { _ in + self.leftBanner.isHidden = true + self.rightBanner.isHidden = true + self.topBanner.isHidden = true + self.bottomBanner.isHidden = true + // reset alpha after hiding to reduce flicker + self.leftBanner.alpha = 1 + self.rightBanner.alpha = 1 + self.topBanner.alpha = 1 + self.bottomBanner.alpha = 1 + } + } +} + +// MARK: - Extensions and Protocols + +extension SwipeableContainer: UIGestureRecognizerDelegate { + func gestureRecognizer(_: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) + -> Bool { + // Allow tap gestures to work alongside pan + otherGestureRecognizer is UITapGestureRecognizer + } +} + +protocol SwipeableContainerDelegate: AnyObject { + func containerDidSwipeRight(_ container: SwipeableContainer) + func containerDidSwipeLeft(_ container: SwipeableContainer) + func containerDidSwipeDown(_ container: SwipeableContainer) + func containerDidSwipeUp(_ container: SwipeableContainer) +} diff --git a/ios/Tsurukame.xcodeproj/project.pbxproj b/ios/Tsurukame.xcodeproj/project.pbxproj index 13b0d4b7..63938446 100644 --- a/ios/Tsurukame.xcodeproj/project.pbxproj +++ b/ios/Tsurukame.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0A943349E17FA7889EFEE41F /* Pods_Tsurukame.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC80AAF9736A570E3C4CC09E /* Pods_Tsurukame.framework */; }; + 22B439052D3FAB9600FADC67 /* SwipeableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22B439042D3FAB8400FADC67 /* SwipeableContainer.swift */; }; 480B243123D6830300FC2A82 /* Rexy in Frameworks */ = {isa = PBXBuildFile; productRef = 480B243023D6830300FC2A82 /* Rexy */; }; 481B2FED23C55AAF0041EF1F /* CurrentLevelReviewTimeItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 481B2FEC23C55AAF0041EF1F /* CurrentLevelReviewTimeItem.swift */; }; 4820306724D849E100B40FDD /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 633632EE201F3490006D582B /* Settings.swift */; }; @@ -260,6 +261,7 @@ 1BBBD142815F6A55FA9D2C21 /* libPods-Tsurukame Complication Extension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Tsurukame Complication Extension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 20A6D3E1C6F7A492EB9DDF0C /* Pods-Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tests.release.xcconfig"; path = "Target Support Files/Pods-Tests/Pods-Tests.release.xcconfig"; sourceTree = ""; }; 21894D9E0883B0107075DD6F /* Pods-Tsurukame.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tsurukame.release.xcconfig"; path = "Target Support Files/Pods-Tsurukame/Pods-Tsurukame.release.xcconfig"; sourceTree = ""; }; + 22B439042D3FAB8400FADC67 /* SwipeableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeableContainer.swift; sourceTree = ""; }; 301BAAC2C21E4A99238D1E80 /* Pods-FontScreenshotter-FontScreenshotterUITests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FontScreenshotter-FontScreenshotterUITests.release.xcconfig"; path = "Target Support Files/Pods-FontScreenshotter-FontScreenshotterUITests/Pods-FontScreenshotter-FontScreenshotterUITests.release.xcconfig"; sourceTree = ""; }; 34E86AD95515CD815B3086D0 /* Pods-Tsurukame.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Tsurukame.debug.xcconfig"; path = "Target Support Files/Pods-Tsurukame/Pods-Tsurukame.debug.xcconfig"; sourceTree = ""; }; 404461BC39B4A19B128F9E69 /* Pods_FontScreenshotter_FontScreenshotterUITests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FontScreenshotter_FontScreenshotterUITests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -580,6 +582,7 @@ 630FD60A1FC45BDF00D21C1F = { isa = PBXGroup; children = ( + 22B439042D3FAB8400FADC67 /* SwipeableContainer.swift */, 631CAD62234AD5FC008CEA73 /* AnswerChecker.swift */, C904455C2395DBAE001BC48B /* AnswerTextField.swift */, 638A5C4D25A471F9001A7AEE /* AppDelegate.swift */, @@ -1482,6 +1485,7 @@ 63F7F52123D3F36E006A31FB /* AttributedModelItem.swift in Sources */, 631CAD6F234B3B8D008CEA73 /* LevelTimeRemainingItem.swift in Sources */, 633DE95D20C5635A00AC25F4 /* TKMKanaInput.m in Sources */, + 22B439052D3FAB9600FADC67 /* SwipeableContainer.swift in Sources */, 63A7021B2C2DBD0D00AB0504 /* MainTabBarViewController.swift in Sources */, 63CBFF29277327920086BA5F /* FontsViewController.swift in Sources */, E881943B2A6BC0CF002C2E1C /* ArtworkModelItem.swift in Sources */,