How to build a context menu like Facebook / Slack on iOS? - ios

I was just looking at Context menu of Facebook and or slack and wanted to create something similar in my App.
I have tried two methods.
First method. Having a in View Table View and sliding it from bottom to create as if it is animated on to the view. But the problem with this is that The navigation controller and Tab bar controller are not hidden and a white patch is shown over the Black (Alpha 30 %).
The second method I tried was showing a new View controller over the current view controller and presenting as a Modal presentation.
let vc = CustomActionTableViewController(nibName: "CustomActionTableViewController", bundle: nil)
vc.modalPresentationStyle = .overFullScreen
self.present(vc, animated: false, completion: nil)
This works okay but the method is too slow as I have to work with lot of Notifications (To send selected index to my main View and then perform action). It is painfully slow.
Could anyone help me with how I can improve the implementation so that I can get the Action sheet similar to Facebook which is smooth and very very fluid

Check this example : Bottom pop Up
Currently I am using this in my app and it's work fine.

Since you mentioned Slack, they actually have open sourced their bottom sheet implementation, PanModal.

Using UIPresentationController and UIPanGestureRecognizer
1- create BottomMenu presentation Controller which will handle the height of your View Controller and blur
class BottomMenuPresentationController: UIPresentationController {
// MARK: - Properties
var blurEffectView: UIVisualEffectView?
var tapGestureRecognizer = UITapGestureRecognizer()
private var topHeightRatio: Float
private var bottomHeightRatio: Float
init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, topHeightRatio: Float, bottomHeightRatio: Float) {
let blurEffect = UIBlurEffect(style: .systemThickMaterialDark)
blurEffectView = UIVisualEffectView(effect: blurEffect)
self.topHeightRatio = topHeightRatio
self.bottomHeightRatio = bottomHeightRatio
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
blurEffectView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissController))
self.blurEffectView?.isUserInteractionEnabled = true
self.blurEffectView?.addGestureRecognizer(tapGestureRecognizer)
}
override var frameOfPresentedViewInContainerView: CGRect {
CGRect(origin: CGPoint(x: 0, y: self.containerView!.frame.height * CGFloat(topHeightRatio)),
size: CGSize(width: self.containerView!.frame.width, height: self.containerView!.frame.height * CGFloat(bottomHeightRatio)))
}
override func presentationTransitionWillBegin() {
self.blurEffectView?.alpha = 0
if let blurEffectView = blurEffectView {
self.containerView?.addSubview(blurEffectView)
}
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (_) in
self.blurEffectView?.alpha = 0.66
}, completion: { (_) in })
}
override func dismissalTransitionWillBegin() {
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (_) in
self.blurEffectView?.alpha = 0
}, completion: { (_) in
self.blurEffectView?.removeFromSuperview()
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
presentedView!.roundCorners([.topLeft, .topRight], radius: 14)
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
presentedView?.frame = frameOfPresentedViewInContainerView
blurEffectView?.frame = containerView!.bounds
}
#objc func dismissController() {
self.presentedViewController.dismiss(animated: true, completion: nil)
}
}
2- create Your ViewController
class BottomMenuVC: UIViewController {
// MARK: - Instances
var hasSetPointOrigin = false
var pointOrigin: CGPoint?
// MARK: - Properties
let topDarkLine: UIView = {
let view = UIView()
view.backgroundColor = UIColor(hexString: "#E1E1E1")
view.layer.cornerRadius = 2
return view
}()
let cancelButn: UIButton = {
let button = UIButton(type: .custom)
button.setAttributedTitle(NSAttributedString(string: "Cancel", attributes: [NSAttributedString.Key.font: UIFont.LatoMedium(size: 17),
NSAttributedString.Key.foregroundColor: UIColor(hexString: "#515151")
]), for: .normal)
button.backgroundColor = UIColor(hexString: "#F1F3F4")
button.layer.cornerRadius = 5.0
button.addTarget(self, action: #selector(cancelButnPressed), for: .touchUpInside)
return button
}()
// MARK: - viewLifeCycle
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .white
view.isUserInteractionEnabled = true
setupMenuView()
}
override func viewDidLayoutSubviews() {
if !hasSetPointOrigin {
hasSetPointOrigin = true
pointOrigin = self.view.frame.origin
}
}
// MARK: - SetupView
func setupMenuView() {
self.view.addSubview(topDarkLine)
self.view.addSubview(cancelButn)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognizerAction(_:)))
view.addGestureRecognizer(panGesture)
topDarkLine.constrainHeight(constant: 4)
topDarkLine.constrainWidth(constant: view.frame.size.width * 0.10)
topDarkLine.centerXInSuperview()
topDarkLine.anchor(top: view.topAnchor, leading: nil, bottom: nil, trailing: nil, padding: .init(top: 8, left: 0, bottom: 0, right: 0))
cancelButn.anchor(top:view.topAnchor, leading: view.leadingAnchor, bottom: nil, trailing: view.trailingAnchor,
padding: .init(top: 16, left: 16, bottom: 0, right: 16))
cancelButn.constrainHeight(constant: 44)
}
// MARK: - Actions
#objc func panGestureRecognizerAction(_ sender: UIPanGestureRecognizer) {
let translation = sender.translation(in: view)
// Not allowing the user to drag the view upward
guard translation.y >= 0 else { return }
// setting x as 0 because we don't want users to move the frame side ways!! Only want straight up or down in the y-axis
view.frame.origin = CGPoint(x: 0, y: self.pointOrigin!.y + translation.y)
if sender.state == .ended {
let dragVelocity = sender.velocity(in: view)
if dragVelocity.y >= 1300 {
// Velocity fast enough to dismiss the uiview
self.dismiss(animated: true, completion: nil)
} else {
// If the dragging isn’t too fast, resetting the view back to it’s original point
UIView.animate(withDuration: 0.3) {
self.view.frame.origin = self.pointOrigin ?? CGPoint(x: 0, y: 400)
}
}
}
}
#objc func cancelButnPressed() {
dismiss(animated: true, completion: nil)
}
}
3- make the viewController that contain the button that will present your menu conforms to UIViewControllerTransitioningDelegate
extension viewController: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
BottomMenuPresentationController(presentedViewController: presented, presenting: presenting, topHeightRatio: 0.6, bottomHeightRatio: 0.4)
}
}
4- set the transitioning delegate to self and present your custom presentation Controller
func showBottomMenu() {
let menu = BottomMenuVC()
menu.coordinator = self
menu.modalPresentationStyle = .custom
menu.transitioningDelegate = self
present(menu, animated: true, completion: nil)
}
check this PanGesture Slidable View article

Related

sheetPresentationController legacy for iOS 12 in Swift (Question)

I'm have to extend the support for a Cocoapod made for target iOS 15, and works showing a UINavigationController as bottom modal, presented for a UIViewController
The problem is self.bottomController.sheetPresentationController is only available for iOS 15, and this must have to works until target 12.0
If I comment the line:
if let sheet = self.bottomController.sheetPresentationController {
sheet.detents = [.large()]
}
and compile success for iOS 12 target, but the content of the modal is showed as fullscreen.
How can imitate or do something to show the content (red text) as modal, as the picture?
I tried using other pods into my pod but without success results. Thanks!
sheetPresentationController only work for ios 15 and later, for previously versions you need to set .custom modalPresentationStype
controller.modalPresentationStyle = .pageSheet
if #available(iOS 15.0, *) {
if let sheet = controller.sheetPresentationController {
sheet.detents = [.medium()]
}
} else {
controller.modalPresentationStyle = .custom
controller.transitioningDelegate = self
}
self.present(controller, animated: true, completion: nil)
// MARK: - UIViewControllerTransitioningDelegate
extension CPPdfPreviewVC: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
PresentationController(presentedViewController: presented, presenting: presenting)
}
}
and Add Presentation controller as given
class PresentationController: UIPresentationController {
let blurEffectView: UIVisualEffectView!
var tapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer()
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
let blurEffect = UIBlurEffect(style: .dark)
blurEffectView = UIVisualEffectView(effect: blurEffect)
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissController))
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.blurEffectView.isUserInteractionEnabled = true
self.blurEffectView.addGestureRecognizer(tapGestureRecognizer)
}
override var frameOfPresentedViewInContainerView: CGRect {
CGRect(origin: CGPoint(x: 0, y: self.containerView!.frame.height * 0.4),
size: CGSize(width: self.containerView!.frame.width, height: self.containerView!.frame.height *
0.6))
}
override func presentationTransitionWillBegin() {
self.blurEffectView.alpha = 0
self.containerView?.addSubview(blurEffectView)
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
self.blurEffectView.alpha = 0.7
}, completion: { (UIViewControllerTransitionCoordinatorContext) in })
}
override func dismissalTransitionWillBegin() {
self.presentedViewController.transitionCoordinator?.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) in
self.blurEffectView.alpha = 0
}, completion: { (UIViewControllerTransitionCoordinatorContext) in
self.blurEffectView.removeFromSuperview()
})
}
override func containerViewWillLayoutSubviews() {
super.containerViewWillLayoutSubviews()
presentedView!.roundCorners([.topLeft, .topRight], radius: 22)
}
override func containerViewDidLayoutSubviews() {
super.containerViewDidLayoutSubviews()
presentedView?.frame = frameOfPresentedViewInContainerView
blurEffectView.frame = containerView!.bounds
}
#objc func dismissController(){
self.presentedViewController.dismiss(animated: true, completion: nil)
}
}
extension UIView {
func roundCorners(_ corners: UIRectCorner, radius: CGFloat) {
let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
layer.mask = mask
}
}

Drag to dismiss a UIPresentationController

I have made a UIPresentationController that fits any view controller and shows up on half of the screen using this tutorial. Now I would love to add drag to dismiss to this. I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app. I thought the iOS 13 modal drag to dismiss would get carried over but it doesn't to this controller but it doesn't.
Every bit of code and tutorial I found had a bad dragging experience. Does anyone know how to do this? I've been trying / searching for the past week. Thank you in advance
Here's my code for the presentation controller
class SlideUpPresentationController: UIPresentationController {
// MARK: - Variables
private var dimmingView: UIView!
//MARK: - View functions
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
setupDimmingView()
}
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
let width = container.bounds.size.width
let height : CGFloat = 300.0
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
}
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView else { return }
containerView?.insertSubview(dimmingView, at: 0)
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|",
options: [],
metrics: nil,
views: ["dimmingView": dimmingView]))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|",
options: [],
metrics: nil,
views: ["dimmingView": dimmingView]))
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1.0
})
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
})
}
func setupDimmingView() {
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(recognizer:)))
dimmingView.addGestureRecognizer(recognizer)
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
}
As your description about the dragging experience you wanted is not that clear, hope I didn't get you wrong.
I'm trying to have the drag feel natural and responsive like the drag experience for "Top Stories" on the Apple iOS 13 stocks app.
What I get is, you want to be able to drag the presented view, dismiss it if it reach certain point, else go back to its original position (and of coz you can bring the view to any position you wanted).
To achieve this, we can add a UIPanGesture to the presentedViewController, then
move the presentedView according to the gesture
dismiss / move back the presentedView
class SlideUpPresentationController: UIPresentationController {
// MARK: - Variables
private var dimmingView: UIView!
private var originalX: CGFloat = 0
//MARK: - View functions
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
setupDimmingView()
}
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
let width = container.bounds.size.width
let height : CGFloat = 300.0
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
}
override func presentationTransitionWillBegin() {
guard let dimmingView = dimmingView else { return }
containerView?.insertSubview(dimmingView, at: 0)
// add PanGestureRecognizer for dragging the presented view controller
let viewPan = UIPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
containerView?.addGestureRecognizer(viewPan)
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "V:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
NSLayoutConstraint.activate(NSLayoutConstraint.constraints(withVisualFormat: "H:|[dimmingView]|", options: [], metrics: nil, views: ["dimmingView": dimmingView]))
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 1.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 1.0
})
}
#objc private func viewPanned(_ sender: UIPanGestureRecognizer) {
// how far the pan gesture translated
let translate = sender.translation(in: self.presentedView)
switch sender.state {
case .began:
originalX = presentedViewController.view.frame.origin.x
case .changed:
// move the presentedView according to pan gesture
// prevent it from moving too far to the right
if originalX + translate.x < 0 {
presentedViewController.view.frame.origin.x = originalX + translate.x
}
case .ended:
let presentedViewWidth = presentedViewController.view.frame.width
let newX = presentedViewController.view.frame.origin.x
// if the presentedView move more than 0.75 of the presentedView's width, dimiss it, else bring it back to original position
if presentedViewWidth * 0.75 + newX > 0 {
setBackToOriginalPosition()
} else {
moveAndDismissPresentedView()
}
default:
break
}
}
private func setBackToOriginalPosition() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = self.originalX
self.presentedViewController.view.layoutIfNeeded()
}, completion: nil)
}
private func moveAndDismissPresentedView() {
// ensure no pending layout change in presentedView
presentedViewController.view.layoutIfNeeded()
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
self.presentedViewController.view.frame.origin.x = -self.presentedViewController.view.frame.width
self.presentedViewController.view.layoutIfNeeded()
}, completion: { _ in
// dimiss when the view is completely move outside the screen
self.presentingViewController.dismiss(animated: true, completion: nil)
})
}
override func dismissalTransitionWillBegin() {
guard let coordinator = presentedViewController.transitionCoordinator else {
dimmingView.alpha = 0.0
return
}
coordinator.animate(alongsideTransition: { _ in
self.dimmingView.alpha = 0.0
})
}
func setupDimmingView() {
dimmingView = UIView()
dimmingView.translatesAutoresizingMaskIntoConstraints = false
dimmingView.backgroundColor = UIColor(white: 0.0, alpha: 0.5)
dimmingView.alpha = 0.0
let recognizer = UITapGestureRecognizer(target: self,
action: #selector(handleTap(recognizer:)))
dimmingView.addGestureRecognizer(recognizer)
}
#objc func handleTap(recognizer: UITapGestureRecognizer) {
presentingViewController.dismiss(animated: true)
}
}
The above code is just an example based on the code you provide, but I hope that explain what's happening under the hood of what you called a drag experience. Hope this helps ;)
Here is the example result:
via GIPHY

YouTube player opening unnecessarily during scrolling of CollectionView

I am working on a chatbot where the different type of response comes from the server and I display the response using UICollectionView cells in chat screen. Different type of cells presents according to server response. when server response with playing video, I am presenting the cell that contains youtube player. I am using https://github.com/kieuquangloc147/YouTubePlayer-Swift. The issue is when I scroll chat screen (collectionView) youtube player is opening again and again. Sometimes it is blocking all the UI element and stop scrolling. I tried different methods but can't able to resolve it. Here is the code:
PlayerView:
import UIKit
class PlayerView: UIView, YouTubePlayerDelegate {
override init(frame: CGRect) {
super.init(frame: frame)
addYotubePlayer()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// youtube player
lazy var youtubePlayer: YouTubePlayerView = {
let viewFrame = UIScreen.main.bounds
let player = YouTubePlayerView(frame: CGRect(x: 0, y: 0, width: viewFrame.width - 16, height: viewFrame.height * 1/3))
player.delegate = self
return player
}()
// used as an overlay to dismiss the youtube player
let blackView = UIView()
// youtube player loader
lazy var playerIndicator: UIActivityIndicatorView = {
let indicator = UIActivityIndicatorView()
indicator.activityIndicatorViewStyle = .whiteLarge
indicator.hidesWhenStopped = true
return indicator
}()
// shows youtube player
func addYotubePlayer() {
if let window = UIApplication.shared.keyWindow {
blackView.frame = window.frame
self.addSubview(blackView)
blackView.backgroundColor = UIColor(white: 0, alpha: 0.5)
let tap = UITapGestureRecognizer(target: self, action: #selector(handleDismiss))
tap.numberOfTapsRequired = 1
tap.cancelsTouchesInView = false
blackView.addGestureRecognizer(tap)
let centerX = UIScreen.main.bounds.size.width / 2
let centerY = UIScreen.main.bounds.size.height / 2
blackView.addSubview(playerIndicator)
playerIndicator.center = CGPoint(x: centerX, y: centerY)
playerIndicator.startAnimating()
blackView.addSubview(youtubePlayer)
youtubePlayer.center = CGPoint(x: centerX, y: centerY)
blackView.alpha = 0
youtubePlayer.alpha = 0
UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseOut, animations: {
self.blackView.alpha = 1
self.youtubePlayer.alpha = 1
}, completion: nil)
}
}
func play(_ videoID: String) {
youtubePlayer.loadVideoID(videoID)
}
#objc func handleDismiss() {
blackView.removeFromSuperview()
UIApplication.shared.keyWindow?.viewWithTag(24)?.removeFromSuperview()
UIApplication.shared.keyWindow?.removeFromSuperview()
}
func playerReady(_ videoPlayer: YouTubePlayerView) {
self.playerIndicator.stopAnimating()
}
func playerStateChanged(_ videoPlayer: YouTubePlayerView, playerState: YouTubePlayerState) {
}
func playerQualityChanged(_ videoPlayer: YouTubePlayerView, playbackQuality: YouTubePlaybackQuality) {
}
}
YouTubePlayerCell (Which I present in collectionView wthe hen server responds for video):
import UIKit
class YouTubePlayerCell: ChatMessageCell {
var player: PlayerView = PlayerView(frame: UIScreen.main.bounds)
override func setupViews() {
super.setupViews()
setupCell()
}
func setupCell() {
messageTextView.frame = CGRect.zero
textBubbleView.frame = CGRect.zero
}
func loadVideo(with videoID: String) {
player.tag = 24
UIApplication.shared.keyWindow?.addSubview(player)
player.play(videoID)
}
override func prepareForReuse() {
super.prepareForReuse()
player.removeFromSuperview()
UIApplication.shared.keyWindow?.viewWithTag(24)?.removeFromSuperview()
}
}
Here is how I am presenting the YouTubePlayerCell in cellForItemAt method of UICollectionView
let message = messages[indexPath.row]
if message.actionType == ActionType.video_play.rawValue {
if let cell = collectionView.dequeueReusableCell(withReuseIdentifier: ControllerConstants.youtubePlayerCell, for: indexPath) as? YouTubePlayerCell {
self.resignResponders()
if let videoId = message.videoData?.identifier {
cell.loadVideo(with: videoId)
}
return cell
}
}
Full Source Code can be found here: https://github.com/imjog/susi_iOS/tree/ytplayer
I can see that in the below code
if let videoId = message.videoData?.identifier {
cell.loadVideo(with: videoId)
}
you are calling loadVideo method, which is responsible for showing the player.
So while scrolling you are reusing the cell and it calls loadVideo method and present the player. so the solution is don't start playing the video by default on displaying the cell, provide a play/pause button on the cell video overlay and on clicking the the button start playing the video.
If my analysis is wrong please let me know, what exact issue you have.
Why do you add the player as a subView each time you have to play the video ? My suggestion would be, as you are adding the player view on the whole screen, you can have just one instance of the view and add it just once(may be at the beginning) and keep it hidden. To play the video just unhide the player and load the video.
Instead best practice would be to have a View controller for Youtube Player and present it with the video id each time you need to play and then dismissing when done.
Thanks for your answers. I solve this by this way:
Rather than presenting Player on setting on the cell, I am now adding a thumbnail to the cell and a button on thumbnail view so that whenever the user clicks play button, it opens a new controller (Previously I was presenting in UIWindow) and presenting it as modalPresentationStyle of overFullScreen by using protocol because cell cannot present a ViewController.
Protocol: (In YouTubePlayerCell class)
protocol PresentControllerDelegate: class {
func loadNewScreen(controller: UIViewController) -> Void
}
Final YouTubePlayer.swift:
import UIKit
import Kingfisher
protocol PresentControllerDelegate: class {
func loadNewScreen(controller: UIViewController) -> Void
}
class YouTubePlayerCell: ChatMessageCell {
weak var delegate: PresentControllerDelegate?
var message: Message? {
didSet {
addThumbnail()
}
}
lazy var thumbnailView: UIImageView = {
let imageView = UIImageView()
imageView.image = ControllerConstants.Images.placeholder
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.layer.cornerRadius = 15
imageView.isUserInteractionEnabled = true
return imageView
}()
lazy var playButton: UIButton = {
let button = UIButton(type: .system)
button.setImage(ControllerConstants.Images.youtubePlayButton, for: .normal)
button.addTarget(self, action: #selector(playVideo), for: .touchUpInside)
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
override func setupViews() {
super.setupViews()
setupCell()
prepareForReuse()
}
func setupCell() {
messageTextView.frame = CGRect.zero
textBubbleView.frame = CGRect(x: 8, y: 0, width: 208, height: 158)
textBubbleView.layer.borderWidth = 0.2
textBubbleView.backgroundColor = .white
}
override func prepareForReuse() {
super.prepareForReuse()
thumbnailView.image = nil
}
func addThumbnail() {
textBubbleView.addSubview(thumbnailView)
textBubbleView.addConstraintsWithFormat(format: "H:|-4-[v0]-4-|", views: thumbnailView)
textBubbleView.addConstraintsWithFormat(format: "V:|-4-[v0]-4-|", views: thumbnailView)
self.downloadThumbnail()
self.addPlayButton()
}
func addPlayButton() {
thumbnailView.addSubview(playButton)
playButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
playButton.widthAnchor.constraint(equalToConstant: 44).isActive = true
playButton.centerXAnchor.constraint(equalTo: thumbnailView.centerXAnchor).isActive = true
playButton.centerYAnchor.constraint(equalTo: thumbnailView.centerYAnchor).isActive = true
}
func downloadThumbnail() {
if let videoID = message?.videoData?.identifier {
let thumbnailURLString = "https://img.youtube.com/vi/\(videoID)/default.jpg"
let thumbnailURL = URL(string: thumbnailURLString)
thumbnailView.kf.setImage(with: thumbnailURL, placeholder: ControllerConstants.Images.placeholder, options: nil, progressBlock: nil, completionHandler: nil)
}
}
#objc func playVideo() {
if let videoID = message?.videoData?.identifier {
let playerVC = PlayerViewController(videoID: videoID)
playerVC.modalPresentationStyle = .overFullScreen
delegate?.loadNewScreen(controller: playerVC)
}
}
}
Delegate implementation in CollectionViewController:
extension ChatViewController: PresentControllerDelegate {
func loadNewScreen(controller: UIViewController) {
self.present(controller, animated: true, completion: nil)
}
}
Final source code can be found here: https://github.com/fossasia/susi_iOS/pull/372

Trying to mimic the Mail.app compose animation keeping a layer in view

I have been trying for a while but I cannot figure out how to create a Compose animation seen in the iOS 10+ when you can drag the new composed email down, then it stays on the bottom and the rest of the app is normally accessed, then when you tap it, it re-shows.
I have created a sample project in which I have a UIViewController that presents another UIViewController which has a UIPanGestureRecognizer in it's UINavigationController that fires the pangesture state analyzer.
I can indeed drag to dismiss it , but I cannot find a way to keep it frame.
Bellow there's a print screen of what I'm trying to accomplish and then my used code to where I'm stuck at.
UIViewController that is the presentingViewController class
//
// ViewController.swift
// dismissLayerTest
//
// Created by Ivan Cantarino on 27/09/17.
// Copyright © 2017 Ivan Cantarino. All rights reserved.
//
import UIKit
class ViewController: UIViewController, UIViewControllerTransitioningDelegate {
#objc let interactor = Interactor()
lazy var presentButton: UIButton = {
let b = UIButton(type: .custom)
b.setTitle("Present", for: .normal)
b.setTitleColor(.black, for: .normal)
b.addTarget(self, action: #selector(didTapPresentButton), for: .touchUpInside)
return b
}()
lazy var testbutton: UIButton = {
let b = UIButton(type: .custom)
b.setTitle("test", for: .normal)
b.setTitleColor(.black, for: .normal)
b.addTarget(self, action: #selector(test), for: .touchUpInside)
return b
}()
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
view.backgroundColor = .white
view.addSubview(presentButton)
presentButton.anchor(top: nil, left: nil, bottom: nil, right: nil, paddingTop: 0, paddinfLeft: 0, paddingBottom: 0, paddingRight: 0, width: 100, height: 100)
presentButton.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
presentButton.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
view.addSubview(testbutton)
testbutton.anchor(top: nil, left: nil, bottom: presentButton.topAnchor, right: nil, paddingTop: 0, paddinfLeft: 0, paddingBottom: 100, paddingRight: 0, width: 100, height: 100)
}
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated.
}
#objc func didTapPresentButton() {
let presentedVC = PresentedViewController()
let navController = UINavigationController(rootViewController: presentedVC)
navController.transitioningDelegate = self
presentedVC.interactor = interactor // new
navController.modalPresentationStyle = .custom
navController.view.layer.masksToBounds = true
present(navController, animated: true, completion: nil)
}
#objc func test() {
print("test")
}
// Handles the presenting animation
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomAnimationForPresentor()
}
// Handles the dismissing animation
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return CustomAnimationForDismisser()
}
// interaction controller, only for dismissing the view;
func interactionControllerForDismissal(using animator: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return interactor.hasStarted ? interactor : nil
}
// delegate do custom modal presentation style
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return CustomPresentationController(presentedViewController: presented, presenting: presenting)
}
}
UIViewController 2 that is the presentedViewController
import Foundation
import UIKit
class PresentedViewController: UIViewController, UIViewControllerTransitioningDelegate, UIGestureRecognizerDelegate {
#objc var interactor: Interactor? = nil
#objc var panGr = UIPanGestureRecognizer()
#objc var panTapRecon = UITapGestureRecognizer()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .green
let leftB = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(didTapCancel))
navigationItem.leftBarButtonItem = leftB
panGr = UIPanGestureRecognizer(target: self, action: #selector(handleGesture))
navigationController?.navigationBar.addGestureRecognizer(panGr)
panTapRecon = UITapGestureRecognizer(target: self, action: #selector(handleNavControllerTapGR))
navigationController?.navigationBar.addGestureRecognizer(panTapRecon)
}
#objc func didTapCancel() {
guard let interactor = interactor else { return }
interactorFinish(interactor: interactor)
dismiss(animated: true, completion: nil)
}
#objc func handleNavControllerTapGR(_ sender: UITapGestureRecognizer) {
print("tap detected")
}
// Swipe gesture recognizer handler
#objc func handleGesture(_ sender: UIPanGestureRecognizer) {
//percentThreshold: This variable sets how far down the user has to drag
//in order to trigger the modal dismissal. In this case, it’s set to 40%.
let percentThreshold:CGFloat = 0.30
// convert y-position to downward pull progress (percentage)
let translation = sender.translation(in: view)
let verticalMovement = translation.y / view.bounds.height
let downwardMovement = fmaxf(Float(verticalMovement), 0.0)
let downwardMovementPercent = fminf(downwardMovement, 1.0)
let progress = CGFloat(downwardMovementPercent)
guard let interactor = interactor else { return }
switch sender.state {
case .began:
interactor.hasStarted = true
self.dismiss(animated: true, completion: nil)
case .changed:
// alterar se o tamanho do presentigViewController (MainTabBarController) for alterado no background
let scaleX = 0.95 + (progress * (1 - 0.95))
let scaleY = 0.95 + (progress * (1 - 0.95))
// Não deixa ultrapassar os 100% de scale (tamanho original)
if (scaleX > 1 && scaleY > 1) { return }
presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: scaleX, y: scaleY);
presentingViewController?.view.layer.masksToBounds = true
interactor.shouldFinish = progress > percentThreshold
interactor.update(progress)
case .cancelled:
interactor.hasStarted = false
interactor.cancel()
case .ended:
interactor.hasStarted = false
if (interactor.shouldFinish) {
interactorFinish(interactor: interactor)
} else {
// repõe o MainTabBarController na posição dele atrás do NewPostController
UIView.animate(withDuration: 0.5, animations: {
self.presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: 0.95, y: 0.95);
self.presentingViewController?.view.layer.masksToBounds = true
let c = UIColor.black.withAlphaComponent(0.4)
let shadowView = self.presentingViewController?.view.viewWithTag(999)
shadowView?.backgroundColor = c
})
interactor.cancel()
}
default: break
}
}
#objc func interactorFinish(interactor: Interactor) {
removeShadow()
interactor.finish()
}
// remove a shadow view
#objc func removeShadow() {
UIView.animate(withDuration: 0.2, animations: {
self.presentingViewController?.view.transform = CGAffineTransform.identity.scaledBy(x: 1.0, y: 1.0);
self.presentingViewController?.view.layer.masksToBounds = true
}) { _ in
}
}
}
Here's an Helper file that has the custom presentations:
//
// Helper.swift
// dismissLayerTest
//
// Created by Ivan Cantarino on 27/09/17.
// Copyright © 2017 Ivan Cantarino. All rights reserved.
//
import Foundation
import UIKit
class Interactor: UIPercentDrivenInteractiveTransition {
#objc var hasStarted = false
#objc var shouldFinish = false
}
extension UIView {
#objc func anchor(top: NSLayoutYAxisAnchor?, left: NSLayoutXAxisAnchor?, bottom: NSLayoutYAxisAnchor?, right: NSLayoutXAxisAnchor?, paddingTop: CGFloat, paddinfLeft: CGFloat, paddingBottom: CGFloat, paddingRight: CGFloat, width: CGFloat, height: CGFloat) {
translatesAutoresizingMaskIntoConstraints = false
if let top = top {
topAnchor.constraint(equalTo: top, constant: paddingTop).isActive = true
}
if let left = left {
leftAnchor.constraint(equalTo: left, constant: paddinfLeft).isActive = true
}
if let bottom = bottom {
bottomAnchor.constraint(equalTo: bottom, constant: -paddingBottom).isActive = true
}
if let right = right {
rightAnchor.constraint(equalTo: right, constant: -paddingRight).isActive = true
}
if width != 0 {
widthAnchor.constraint(equalToConstant: width).isActive = true
}
if height != 0 {
heightAnchor.constraint(equalToConstant: height).isActive = true
}
}
#objc func roundCorners(corners:UIRectCorner, radius: CGFloat) {
let path = UIBezierPath(roundedRect: self.bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
let mask = CAShapeLayer()
mask.path = path.cgPath
self.layer.mask = mask
}
}
class CustomAnimationForDismisser: NSObject, UIViewControllerAnimatedTransitioning {
// Tempo da animação
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.27
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get the set of relevant objects.
let containerView = transitionContext.containerView
guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {
print("Returning animateTransition VC")
return
}
// from view só existe no dismiss
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from) else {
print("Failed to instantiate fromView: CustomAnimationForDismisser()")
return
}
// Set up some variables for the animation.
let containerFrame: CGRect = containerView.frame
var fromViewFinalFrame: CGRect = transitionContext.finalFrame(for: fromVC)
fromViewFinalFrame = CGRect(x: 0, y: containerFrame.size.height, width: containerFrame.size.width, height: containerFrame.size.height)
// Animate using the animator's own duration value.
UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseOut, animations: {
fromView.frame = fromViewFinalFrame
}) { (finished) in
let success = !(transitionContext.transitionWasCancelled)
// Notify UIKit that the transition has finished
transitionContext.completeTransition(success)
}
}
}
class CustomAnimationForPresentor: NSObject, UIViewControllerAnimatedTransitioning {
// Tempo da animação
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return 0.2
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
// Get the set of relevant objects.
let containerView = transitionContext.containerView
// obtém os VCs para não o perder na apresentação (default desaparece por trás)
guard let fromVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from) else {//, let toVC = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to) else {
print("Returning animateTransition VC")
return
}
// gets the view of the presented object
guard let toView = transitionContext.view(forKey: UITransitionContextViewKey.to) else { return }
// Set up animation parameters.
toView.transform = CGAffineTransform(translationX: 0, y: containerView.bounds.height)
// Always add the "to" view to the container.
containerView.addSubview(toView)
// Animate using the animator's own duration value.
UIView.animate(withDuration: 0.35, delay: 0, options: .curveEaseOut, animations: {
// Zooms out da MainTabBarController - o VC
fromVC.view.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
// propriedades declaradas no CustomPresentationController() // Anima o presented view
toView.transform = .identity
}, completion: { (finished) in
let success = !(transitionContext.transitionWasCancelled)
// So it avoids view stacks and overlap issues
if (!success) { toView.removeFromSuperview() }
// Notify UIKit that the transition has finished
transitionContext.completeTransition(success)
})
}
}
class CustomPresentationController: UIPresentationController {
override init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController!) {
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
}
// Tamanho desejado para o NewPostController
override var frameOfPresentedViewInContainerView: CGRect {
guard let containerBounds = containerView?.bounds else {
print("Failed to instantiate container bounds: CustomPresentationController")
return .zero
}
return CGRect(x: 0.0, y: 0.0, width: containerBounds.width, height: containerBounds.height)
}
// Garante que o frame do view controller a mostrar, se mantém conforme desenhado na função frameOfPresentedViewInContainerView
override func containerViewWillLayoutSubviews() {
presentedView?.frame = frameOfPresentedViewInContainerView
}
}
This desired effect can also be seen in other apps, such like Music app, Stack Exchange/Overflow iOS App
Does anyone have a hint on how can this be accomplished? I feel like I'm really close to achieve it, but I can't find a way to keep the dismissed view with a layer on screen.
The project above can be found here
Thank you very much.
Regards.
I would suggest that Apple (in the animated screen gif you have so helpfully provided) is not using a presented view controller. If it were, the presenting view controller would not be able to shrink its view — and on dismissal, the presented view controller's view would completely disappear.
I would say that underlying this interface is a parent view controller with multiple child view controllers (or maybe just a normal view controller with two child views). Thus, we can display the two child views wherever and however we like. Your animated gif shows two possible arrangements of the two child views: overlapping, and one above the other with the second view just barely visible from the bottom of the screen.

iOS - Dismiss presented view controller touching outside its View

I have a CustomPresentationController which animates in and out with custom animations;
This specific controller gets presented, more less at 50% of the screen size, and when I present it, I add a shadow-gray view to the presentingViewController so it adds some depth.
I can only dismiss the presentedViewController if I tap the cancel button in the NavBar which I call the default dismiss(:) method.
What I'm trying to accomplish is to detect a tap outside the presentedViewController, maybe inside the gray zone, so I can dismiss the presentedViewController, somehow like dismissing an ActionSheet but I've failed to do it. Let me explain what I've tried so far.
I tried to add a UITapGestureRecognizer to the shadow-gray view but since I'm presenting a different controller, the app-engine might think that since the shadow view isn't on the top hierarchy view it might not be accessible so it 'blocks' the recognizer - whenever I tap it, the gesture handles doesn't fire.
I'm implementing now in addition a swipe down to dismiss, which I can make it easily, but I really wanted the tap-outside feature to work as well.
Any hint on how can I approach this?
The apps image is the following:
My solution:
In presenting view controller (aka ViewControllerA):
let storyboard = UIStoryboard(name: "Main", bundle: nil)
let vcb = storyboard.instantiateViewController(withIdentifier: "ViewControllerB") as! ViewControllerB // ViewControllerB is the presented view controller
vcb.modalPresentationStyle = .custom
vcb.transitioningDelegate = self
modalRatio = Float(0.5) // modalRatio is an object property
self.present(pvc, animated: true)
ViewControllerA shall also implement Transitioning delegate:
extension ViewControllerA: UIViewControllerTransitioningDelegate {
func presentationController(forPresented presented: UIViewController, presenting: UIViewController?, source: UIViewController) -> UIPresentationController? {
return PartialSizePresentController(presentedViewController: presented, presenting: presenting, withRatio: modalRatio ?? 0.5) // modal ratio is configurable using modalRatio property
}
}
Then, implement the presentation controller (aka PartialSizePresentController), so that it also handles tap gesture:
class PartialSizePresentController: UIPresentationController {
let heightRatio : CGFloat
init(presentedViewController: UIViewController, presenting presentingViewController: UIViewController?, withRatio ratio: Float = 0.5) {
heightRatio = CGFloat(ratio)
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
}
override var frameOfPresentedViewInContainerView: CGRect {
guard let cv = containerView else { fatalError("No container view available") }
return CGRect(x: 0, y: cv.bounds.height * (1 - heightRatio), width: cv.bounds.width, height: cv.bounds.height * heightRatio)
}
override func presentationTransitionWillBegin() {
let bdView = UIView(frame: containerView!.bounds)
bdView.backgroundColor = UIColor.black.withAlphaComponent(0.5)
containerView?.addSubview(bdView)
bdView.addSubview(presentedView!)
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(PartialSizePresentController.handleTap(_:)))
bdView.addGestureRecognizer(tapGesture)
}
#objc func handleTap(_ sender: UITapGestureRecognizer) {
presentedViewController.dismiss(animated: true, completion: nil)
}
}
I just had to implement this in one of my app.
I made it worked by adding a button that covers the entire view and this button, once tapped triggers the VC to be dismissed.
Once the button is added you can add your custom View on top.
So far it looks like it's working pretty well.
My code below (I do everything programmatically, no storyboard)
//—————————————————————————————
// MARK: View Life Cycle
//—————————————————————————————
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor.clear //VC view background transparent
setupUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
//Animate blackView opacity to 1 to give some depth
UIView.animate(withDuration: 0.4, delay: 0.2, options: .curveEaseInOut, animations: {
self.blackView.alpha = 1
})
}
//————————————————
// MARK: Setup UI
//————————————————
let blackView: UIView = {
let view = UIView()
view.alpha = 0.0
view.backgroundColor = UIColor.black.withAlphaComponent(0.6)
return view
}()
//Invisible button which covers the entire view that can be tapped
lazy var dismissLayerBtn: UIButton = {
let btn = UIButton()
btn.addTarget(self, action: #selector(tapToDismiss), for: .touchUpInside)
return btn
}()
#objc func tapToDismiss() {
print("tapToDimiss")
self.dismiss(animated: true, completion: nil)
}
let milestonePickerView: MilestonePickerView = {
let view = MilestonePickerView(frame: .zero)
return view
}()
func setupUI() {
view.addSubview(blackView)
view.addSubview(dismissLayerBtn)
view.addSubview(milestonePickerView) //Important to add the customView after the button.
blackView.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)
dismissLayerBtn.anchor(top: view.topAnchor, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 0, paddingBottom: 0, paddingRight: 0, width: 0, height: 0)
milestonePickerView.anchor(top: nil, left: view.leftAnchor, bottom: view.bottomAnchor, right: view.rightAnchor, paddingTop: 0, paddingLeft: 20, paddingBottom: 40, paddingRight: 20, width: 0, height: 400)
//I'm using a custom extension to setup constraints (anchors)
}
If you're using storyboard, make sure you put the invisible button under the custom view.
I hope this helps.
Try with my below code:
You need to implement this method inside you presented controller which you are working with as a popup.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
// Here write down you logic to dismiss controller
}
Hope this will work. :D
You were on the right track with the UITapGestureRecognizer. Just make sure you implement the shouldRecognizeSimultaneouslyWith as such:
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
This should allow the gesture to fire correctly.
// it works for me
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
var touch: UITouch? = touches.first
if touch?.view == yourView {
navigationController?.popViewController(animated: true)
dismiss(animated: true, completion: nil)
}
}

Resources