Instantly share code, notes, and snippets.
Created
March 19, 2021 13:58
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save FlorianHardyDev/f58b3797ccdda8a9324a6fbc11dff12c to your computer and use it in GitHub Desktop.
Full player code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import AVKit | |
| import Lottie | |
| import UIKit | |
| class FloatingButtonController: UIViewController { | |
| // MARK: - Variables | |
| private let window = FloatingButtonWindow() | |
| private(set) var button: UIView! | |
| private(set) var deleteView: UIView! | |
| private(set) var insideView: UIView! | |
| private(set) var assetView: AssetView! | |
| private(set) var iconeView: UIImageView! | |
| weak var floatingButtonDelegate: FloatingButtonDelegate? | |
| var rotatingProgressBar = RotatingCircularGradientProgressBar() | |
| var deletingPoint = CGPoint.zero | |
| var statusBarStyle: UIStatusBarStyle = .default | |
| override var preferredStatusBarStyle: UIStatusBarStyle { | |
| return statusBarStyle | |
| } | |
| // MARK: - init | |
| required init?(coder: NSCoder) { | |
| super.init(coder: coder) | |
| } | |
| init() { | |
| super.init(nibName: nil, bundle: nil) | |
| window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) | |
| window.isHidden = false | |
| window.rootViewController = self | |
| NotificationCenter.default.addObserver(self, | |
| selector: #selector(keyboardDidShow(note:)), | |
| name: UIResponder.keyboardDidShowNotification, | |
| object: nil) | |
| PlayerManager.shared.addObserver(self) | |
| } | |
| deinit { | |
| PlayerManager.shared.removeObserver(self) | |
| NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidShowNotification, object: nil) | |
| } | |
| // MARK: - LifeCycle | |
| override func viewDidLayoutSubviews() { | |
| super.viewDidLayoutSubviews() | |
| setNeedsStatusBarAppearanceUpdate() | |
| insideView?.layer.masksToBounds = false | |
| insideView?.layer.applySketchShadow(color: UIColor.darkGray, alpha: 0.4, xVal: 0, yVal: 0, blur: 6, spread: 0) | |
| insideView?.layer.shouldRasterize = true | |
| insideView?.layer.rasterizationScale = UIScreen.main.scale | |
| } | |
| override func loadView() { | |
| // The init of the viewcontroller view | |
| let customView = UIView() | |
| // The init of the fab | |
| let xPos = UIScreen.main.bounds.size.width - Constants.offset - Constants.buttonWidth / 2 | |
| let yPos = UIScreen.main.bounds.size.height - HomeTabBarViewController.Constants.tabBarHeight | |
| - Constants.buttonHeight - Constants.offset | |
| var initialPoint = CGPoint(x: xPos, y: yPos) | |
| if let lastPosition = PlayerManager.shared.smallPlayerLastLocation { | |
| initialPoint = lastPosition | |
| } else { | |
| PlayerManager.shared.smallPlayerLastLocation = initialPoint | |
| } | |
| // The init of the inside view | |
| self.setupInsideView() | |
| // The init of the delete button | |
| self.setupDeleteButton() | |
| // The init of the animation (lottie) | |
| self.setupAssetView() | |
| // The paused state | |
| self.setupPausedState() | |
| // The loading state | |
| self.setupRotatingProgressBar() | |
| // Adding all the views in the insideView | |
| self.insideView.addSubviewToCenterContainer(self.iconeView) | |
| self.insideView.addSubviewToCenterContainer(self.rotatingProgressBar) | |
| self.insideView.addSubviewToCenterContainer(self.assetView) | |
| // The buttonView | |
| let buttonView = UIView(frame: CGRect(x: initialPoint.x - Constants.buttonWidth / 2, | |
| y: initialPoint.y - Constants.buttonHeight / 2, | |
| width: Constants.buttonWidth, | |
| height: Constants.buttonHeight)) | |
| buttonView.backgroundColor = UIColor.clear | |
| buttonView.layer.cornerRadius = 0.5 * buttonView.bounds.size.width | |
| buttonView.clipsToBounds = true | |
| // Adding the insideView in the button | |
| buttonView.addSubviewToCenterContainer(self.insideView) | |
| // Adding delete and button in the view | |
| customView.addSubview(self.deleteView) | |
| self.hideDeleteView() | |
| customView.addSubview(buttonView) | |
| self.view = customView | |
| self.button = buttonView | |
| // Adding the tap | |
| let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) | |
| self.button.addGestureRecognizer(tapGesture) | |
| // Set the window fab | |
| window.button = button | |
| // Adding the gesture | |
| let panner = UIPanGestureRecognizer(target: self, action: #selector(panDidFire(panner:))) | |
| self.button.addGestureRecognizer(panner) | |
| } | |
| } | |
| // MARK: - Setup | |
| extension FloatingButtonController { | |
| private func setupInsideView() { | |
| self.insideView = UIView(frame: CGRect(x: Constants.buttonWidth / 2 - Constants.insideWidth / 2, | |
| y: Constants.buttonHeight / 2 - Constants.insideHeight / 2, | |
| width: Constants.insideWidth, | |
| height: Constants.insideHeight)) | |
| self.insideView.backgroundColor = R.color.accent_color() | |
| self.insideView.layer.cornerRadius = 0.5 * self.insideView.bounds.size.width | |
| self.insideView.clipsToBounds = true | |
| } | |
| private func setupDeleteButton() { | |
| let rect = UIScreen.main.bounds.insetBy(dx: 0, dy: Constants.offset + Constants.buttonHeight / 2) | |
| self.deletingPoint = CGPoint(x: rect.midX, y: rect.maxY) | |
| self.deleteView = UIView(frame: CGRect(x: deletingPoint.x - Constants.deleteWidth / 2, | |
| y: deletingPoint.y - Constants.deleteHeight / 2, | |
| width: Constants.deleteWidth, | |
| height: Constants.deleteHeight)) | |
| self.deleteView.borderWidth = 2 | |
| self.deleteView.borderColor = R.color.grey_dusty() | |
| let deleteImage = UIImageView(image: R.image.ico_cross()) | |
| deleteImage.translatesAutoresizingMaskIntoConstraints = false | |
| deleteImage.tintColor = UIColor.white | |
| self.deleteView.addSubviewToCenterContainer(deleteImage) | |
| self.deleteView.backgroundColor = UIColor.black.withAlphaComponent(0.3) | |
| self.deleteView.layer.cornerRadius = 0.5 * self.deleteView.bounds.size.width | |
| self.deleteView.borderColor = UIColor.white | |
| self.deleteView.borderWidth = 2 | |
| self.deleteView.clipsToBounds = true | |
| } | |
| private func setupAssetView() { | |
| self.assetView = AssetView() | |
| self.assetView.frame = insideView.bounds.inset(by: UIEdgeInsets(horizontal: 25, vertical: 25)) | |
| self.assetView.alpha = 0 | |
| guard let safeAssetView = assetView, let url = R.file.soundbarJson() else { | |
| return | |
| } | |
| safeAssetView.configure(with: .lottie(resource: url, loopMode: .loop, closure: nil, completion: nil), | |
| contentMode: .scaleAspectFill) | |
| } | |
| private func setupPausedState() { | |
| self.iconeView = UIImageView() | |
| self.iconeView.frame = self.assetView.frame | |
| self.iconeView.image = R.image.ico_soundbar_off() | |
| self.iconeView.tintColor = UIColor.white | |
| } | |
| private func setupRotatingProgressBar() { | |
| self.rotatingProgressBar = RotatingCircularGradientProgressBar() | |
| self.rotatingProgressBar.frame = insideView.bounds.inset(by: UIEdgeInsets(horizontal: 10, vertical: 10)) | |
| self.rotatingProgressBar.color = UIColor.white | |
| self.rotatingProgressBar.gradientColor = UIColor.white | |
| self.rotatingProgressBar.backgroundColor = UIColor.clear | |
| self.rotatingProgressBar.progress = 0.85 | |
| } | |
| } | |
| // MARK: - Private Methods | |
| extension FloatingButtonController { | |
| private func hideDeleteView() { | |
| self.deleteView?.alpha = 0 | |
| } | |
| private func showDeleteView() { | |
| self.deleteView?.alpha = 1 | |
| } | |
| private func updateLoadingState(isLoading: Bool) { | |
| if isLoading { | |
| // force the animation to be recreated | |
| self.rotatingProgressBar.isHidden = false | |
| self.rotatingProgressBar.animate() | |
| } else { | |
| self.rotatingProgressBar.removeAnimation() | |
| self.rotatingProgressBar.isHidden = true | |
| } | |
| } | |
| private func snapButtonToSocket(deleteZone: CGRect) { | |
| if deleteZone.contains(button.center) { | |
| button.center = deletingPoint | |
| deletePlayer() | |
| self.view.isHidden = true | |
| } else { | |
| let bestSocket = getBestSocket(deleteZoneCenter: deletingPoint) | |
| if bestSocket == deletingPoint { | |
| button.center = deletingPoint | |
| deletePlayer() | |
| } else { | |
| button.center = bestSocket | |
| PlayerManager.shared.smallPlayerLastLocation = bestSocket | |
| } | |
| } | |
| } | |
| } | |
| // MARK: - Calcul Methods | |
| extension FloatingButtonController { | |
| private func getPossiblePoints() -> [CGPoint] { | |
| var possiblePoints: [CGPoint] = [] | |
| // the current y position | |
| var yPos: CGFloat = button.center.y | |
| let minY = Constants.offset + Constants.buttonHeight / 2 | |
| let maxY = UIScreen.main.bounds.size.height - Constants.offset - Constants.buttonHeight / 2 | |
| // Check if fab is out of limits | |
| if button.center.y < minY { | |
| // limit top | |
| yPos = minY | |
| } else if button.center.y > maxY { | |
| // limit bottom | |
| yPos = maxY | |
| } | |
| // limit right | |
| possiblePoints.append(CGPoint(x: UIScreen.main.bounds.size.width - Constants.offset - Constants.buttonWidth / 2, | |
| y: yPos)) | |
| // limit left | |
| possiblePoints.append(CGPoint(x: Constants.offset + Constants.buttonWidth / 2, y: yPos)) | |
| // return the possible points | |
| return possiblePoints | |
| } | |
| private func getBestSocket(deleteZoneCenter: CGPoint) -> CGPoint { | |
| var bestSocket = CGPoint.zero | |
| var distanceToBestSocket = CGFloat.infinity | |
| var possiblePoints: [CGPoint] = getPossiblePoints() | |
| possiblePoints.append(deleteZoneCenter) | |
| for socket in possiblePoints { | |
| let distance = hypot(button.center.x - socket.x, button.center.y - socket.y) | |
| if distance < distanceToBestSocket { | |
| distanceToBestSocket = distance | |
| bestSocket = socket | |
| } | |
| } | |
| return bestSocket | |
| } | |
| private func deletePlayer() { | |
| removeWindow() | |
| floatingButtonDelegate?.closePodcastPlayer() | |
| } | |
| } | |
| // MARK: - Actions | |
| extension FloatingButtonController { | |
| func playAnimation() { | |
| guard let safeAssetView = assetView else { | |
| return | |
| } | |
| safeAssetView.play() | |
| UIView.animate(withDuration: Constants.animationDuration) { | |
| safeAssetView.alpha = 1 | |
| } | |
| } | |
| func stopAnimation() { | |
| guard let safeAssetView = assetView else { | |
| return | |
| } | |
| safeAssetView.stop() | |
| UIView.animate(withDuration: Constants.animationDuration) { | |
| safeAssetView.alpha = 0 | |
| } | |
| } | |
| @objc | |
| func handleTap() { | |
| self.floatingButtonDelegate?.onSmallPlayerTapped() | |
| } | |
| @objc | |
| func keyboardDidShow(note: NSNotification) { | |
| window.windowLevel = UIWindow.Level(rawValue: 0) | |
| window.windowLevel = UIWindow.Level(rawValue: .greatestFiniteMagnitude) | |
| } | |
| @objc | |
| func panDidFire(panner: UIPanGestureRecognizer) { | |
| let translation = panner.translation(in: view) | |
| panner.setTranslation(CGPoint.zero, in: view) | |
| if panner.state == .began { | |
| // Animate the appearing of the delete button | |
| UIView.animate(withDuration: Constants.animationDuration) { | |
| self.showDeleteView() | |
| } | |
| } | |
| if panner.state == .changed { | |
| let newButtonView = UIView(frame: CGRect(x: self.button.frame.origin.x + translation.x, | |
| y: self.button.frame.origin.y + translation.y, | |
| width: Constants.buttonWidth, | |
| height: Constants.buttonHeight)) | |
| // Move the delete button according to the fab button | |
| let ratio: CGFloat = 0.1 | |
| var tempDeleteCenter = self.deleteView.center | |
| tempDeleteCenter.x += translation.x * ratio | |
| let yPos = tempDeleteCenter.y + translation.y * ratio | |
| let yMaxPos = UIScreen.main.bounds.size.height - Constants.offset - self.deleteView.bounds.size.height / 2 | |
| tempDeleteCenter.y = yPos > yMaxPos ? yMaxPos : yPos | |
| // Check if the fab intersect with delete button | |
| let diffX = Constants.deleteWidth - (Constants.buttonWidth / 2) | |
| let diffY = Constants.deleteHeight - (Constants.buttonHeight / 2) | |
| let deleteZone = self.deleteView.frame.insetBy(dx: diffX, dy: diffY) | |
| var deleteTmpNewButtonOrigin = newButtonView.frame.origin | |
| deleteTmpNewButtonOrigin.x += (Constants.buttonWidth / 2) | |
| deleteTmpNewButtonOrigin.y += (Constants.buttonHeight / 2) | |
| let isIn = deleteZone.contains(newButtonView.frame.origin) | |
| if isIn { | |
| var tempNewButtonDeleteCenter = tempDeleteCenter | |
| tempNewButtonDeleteCenter.x -= (Constants.buttonWidth / 2) | |
| tempNewButtonDeleteCenter.y -= (Constants.buttonHeight / 2) | |
| newButtonView.frame.origin = tempNewButtonDeleteCenter | |
| } | |
| self.button.frame = newButtonView.frame | |
| self.deleteView.center = tempDeleteCenter | |
| // Calculate the final fab position during the drag | |
| self.button.center = isIn ? self.deleteView.center : newButtonView.center | |
| } | |
| if panner.state == .ended || panner.state == .cancelled { | |
| // The delete zone has to be calculated before reseting the delete view position ! | |
| let diffX = Constants.deleteWidth - Constants.buttonWidth | |
| let diffY = Constants.deleteHeight - Constants.buttonHeight | |
| let deleteZone = self.deleteView.frame.insetBy(dx: diffX, dy: diffY) | |
| // Now, we can safely update the delete view position | |
| // (not in animation, otherwise it does not work) | |
| self.deleteView.center = CGPoint(x: self.deletingPoint.x, | |
| y: self.deletingPoint.y) | |
| UIView.animate(withDuration: Constants.animationDuration) { | |
| self.snapButtonToSocket(deleteZone: deleteZone) | |
| self.hideDeleteView() | |
| } | |
| } | |
| } | |
| func removeWindow() { | |
| window.windowLevel = UIWindow.Level(rawValue: -1) | |
| window.isHidden = true | |
| } | |
| func showWindow() { | |
| window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) | |
| window.isHidden = false | |
| } | |
| } | |
| // MARK: - PodcastPlayerObserver | |
| extension FloatingButtonController: PodcastPlayerObserver { | |
| func podcastPlayer(_ player: AVPlayer?, didStartPlaying item: AVPlayerItem?) { | |
| if let item = PlayerManager.shared.podcastPlayer.currentItem, | |
| PlayerManager.shared.isPodcastPlaying(), | |
| item.isPlaybackLikelyToKeepUp { | |
| self.playAnimation() | |
| } | |
| } | |
| func podcastPlayer(_ player: AVPlayer?, didPausePlaybackOf item: AVPlayerItem?) { | |
| self.stopAnimation() | |
| } | |
| func podcastPlayer(_ player: AVPlayer?, isLoading: Bool, item: AVPlayerItem?) { | |
| self.updateLoadingState(isLoading: isLoading) | |
| } | |
| } | |
| // MARK: - UIWindow | |
| private class FloatingButtonWindow: UIWindow { | |
| var button: UIView? | |
| init() { | |
| super.init(frame: UIScreen.main.bounds) | |
| backgroundColor = nil | |
| } | |
| required init?(coder aDecoder: NSCoder) { | |
| super.init(coder: aDecoder) | |
| } | |
| fileprivate override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { | |
| guard let button = button else { return false } | |
| let buttonPoint = convert(point, to: button) | |
| return button.point(inside: buttonPoint, with: event) | |
| } | |
| } | |
| // MARK: - Constants | |
| extension FloatingButtonController { | |
| struct Constants { | |
| static let offset: CGFloat = 20 | |
| static let buttonWidth: CGFloat = 60 | |
| static let buttonHeight: CGFloat = 60 | |
| static let insideHeight: CGFloat = 50 | |
| static let insideWidth: CGFloat = 50 | |
| static let deleteHeight: CGFloat = 50 | |
| static let deleteWidth: CGFloat = 50 | |
| static let animationDuration: TimeInterval = 0.3 | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| protocol FloatingButtonDelegate: AnyObject { | |
| func onSmallPlayerTapped() | |
| func closePodcastPlayer() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment