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 */,