Skip to content

Instantly share code, notes, and snippets.

@FlorianHardyDev
Created March 19, 2021 13:58
Show Gist options
  • Select an option

  • Save FlorianHardyDev/f58b3797ccdda8a9324a6fbc11dff12c to your computer and use it in GitHub Desktop.

Select an option

Save FlorianHardyDev/f58b3797ccdda8a9324a6fbc11dff12c to your computer and use it in GitHub Desktop.
Full player code
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
}
}
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