WKWebKit takeSnapshot(with snapshotConfiguration:) not working on iOS device - ios

Am I doing something wrong...?
On iOS, I am wanting to take a snapshot of a full web page. Until iOS 11, this previously wasn't possible without dealing with a whole bunch of logic to scroll the WKWebView's scrollView, etc.
The following bug was reported to Webkit and fixed: https://bugs.webkit.org/show_bug.cgi?id=161450
Resulting in this change: https://trac.webkit.org/changeset/212929/webkit
Which now gives us a nice API on iOS 11 to take snapshot of WKWebViews content: https://developer.apple.com/documentation/webkit/wkwebview/2873260-takesnapshotwithconfiguration
This is all pretty great, except, I simply cannot get a snapshot when using this method on a device. When running the app in Simulator it work's great.
I've added two images below for comparison. Both are the images that are returned with takeSnapshotWithConfiguration:completionHandler:, however one is blank for the most part.
I've tried playing around with the web view's frame, the configuration, etc. but with no luck.
I am thinking that perhaps Simulator links against the macOS version of WebKit and somehow it works fine there. One issue, as well is that the WKSnapshotConfiguration header also doesn't appear to be exposed in Swift, so I had to add a bridging header that imports it.
Here's a sample project for anyone who's curious: https://github.com/runmad/WebKitSnapshotTest
UPDATE 10/20:
So this fixes it but is a nasty hack:
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// webView.scrollView.contentSize == .zero initially, so put in a delay.
// It still may be .zero, though. In that case we'll just call the
// delegate method again.
let deadlineTime = DispatchTime.now() + .milliseconds(300)
DispatchQueue.main.asyncAfter(deadline: deadlineTime) {
guard webView.scrollView.contentSize.height > 0 else {
self.webView(webView, didFinish: navigation)
return
}
// So this works... If you set the .frame of the webView
// to that of the just loaded .scrollView and then load the
// same URL again, the resulting .frame and .scrollView will
// be the same size and the image render on a device will be
// the correct size.
guard self.hasLoadedOnce else {
webView.frame = CGRect(x: self.view.bounds.width, y: 0, width: self.view.bounds.width, height: webView.scrollView.contentSize.height)
webView.load(URLRequest(url: URL(string: "https://www.apple.com")!))
self.hasLoadedOnce = true
return
}
webView.snapshotWebView(completionHandler: { (image, _) in
self.updateWith(image)
})
}
}
So couple of issues:
(asyncAfter I knew of this one already and had that in there) Have to add a delay for the snapshot, since the scrollView still might be .zero for a little bit
Setting the size of the frame of the WKWebView to that of the correct scrollView size wasn’t working
Setting the frame and THEN RELOADING the WKWebView in what will be it’s true size works
UPDATE 03/11:
My radar (35094298) was duped today to 33812691 and closed, which means Apple is aware of the issue, at least.

Swift 4
Here's what worked for me (note: I used the extension from the sample project provided).
Inside the webView(didFinish navigation) I used JS to find the content width and height. I then set a global rect variable in order to pass it to snapshot() later in a button press. I could not get the snapshot() to work when included in the following code after the constraint is set.
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// To get the constraints properly set for preparation for the snapshot, the setup must be performed here.
self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
if complete != nil {
self.webView.evaluateJavaScript("document.body.scrollHeight", completionHandler: { (height, error) in
guard let contentHeight = height as? CGFloat else { print("Content height could not be obtained"); return }
self.webView.evaluateJavaScript("document.body.scrollWidth", completionHandler: { (width, error) in
let contentWidth = width as! CGFloat
self.rect = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
self.heightConstraint.constant = contentHeight
// Because it is known that the web page has completely loaded, it is safe to enable a snapshot to be taken
self.takeSnapButton.isEnabled = true
})
})
}
})
}
For me, taking a snapshot() had to be a new action, in my case, a separate button press. My conclusion is that the constraint needs to be set and the view reloaded in order for the content to properly resize.
#IBAction func takeSnapButton(_ sender: Any) {
// It is probably safe to assume that there is definitely an image at this point but to be extra
guard let image = self.webView.snapshot(of: self.rect) else { print("An image could not be taken"); return}
}

Related

UIKit: can hidden views still impact the app memory?

I have a view which can have several different states (display a waiting animation, a count down, a recording screen...), each state corresponding to one of this view's direct subviews:
My idea then is to have a status variable, and add a property observer to show only the related view:
enum Status {
case waiting
case countDown
// ...
}
var status: Status = .loading { didSet { showRelevantView() } }
func showRelevantView() {
waitingView.isHidden = status != .waiting
countDownView.isHidden = status != .countDown
// ...
}
Given this, only one of the six subviews would be shown. I would like to know, however, if the five other subviews may put pressure on the memory, and if so, what would be a good solution to improve this?
Thank you for your help
Yes, hidden views use memory. How much? Well, it depends of course on the complexity of the views.
Try this simple example:
class HiddenViewsTestViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
// add a tap gesture recognizer
let t = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
view.addGestureRecognizer(t)
}
#objc func tapped(_ g: UITapGestureRecognizer) -> Void {
// add 500 hidden views
for _ in 1...500 {
let v = UIView()
v.backgroundColor = .cyan
v.frame = CGRect(x: 0, y: 0, width: 200, height: 300)
view.addSubview(v)
v.isHidden = true
}
}
}
When you launch it, select the Debug Navigator in the Navigator pane (in Xcode). Note the memory value. With each tap in the view, the code will add 500 hidden views. Note the corresponding increases in memory used.
To determine the effect your app with your views, you should Profile your app and use Instruments to get a clear measure of the memory being used.
If it's a relatively small amount, you probably have nothing to worry about... but, as we have no idea what your hidden views are, or what else you're doing in this situation, you can only make the decision after you've done the testing / profiling.

Set video on old page to 0.0 seconds when scrolled to new page

I have a scrollView with pagingEnabled set to true. Each of these pages contains one video covering the entire screen. The videos are added using a for loop and a class called PlayerView (not only adding the video but also information about the video). Within the PlayerView class I have a function which starts/stops playing the video in the respective page and all that works fine.
My problem is I would like to set the video to the start of it and stop it there (at 0.0 seconds so to say) whenever the page containing that video is not visible anymore (with self.player?.seek(to: kCMTimeZero) and self.player?.pause()).
In my scrollView I have a function which detects what page has appeared and also states the previous page when the user scrolls from one page to another (in form of an index, e.g. when scrolled from page 2 to page 3 the function will print: "1 2"). To stick to the example: I would like to set the video on page 2 (index: 1) to 0 and pause it.
create scrollView (in class HomeController: UIViewController, UIScrollViewDelegate)
func initiateScrollView() {
//create scrollView with paging enabled
let scrollView = UIScrollView(frame: view.bounds)
scrollView.isPagingEnabled = true
scrollView.delegate = self
view.addSubview(scrollView)
//get page size
let pageSize = view.bounds.size
//three simple UIView are created (view1, view2, view3)
//array with individual views
let pagesViews = [view1, view2, view3]
//amount of views
let numberOfPages = pagesViews.count
//add subviews (pages)
for (pageIndex, page) in pagesViews.enumerated(){
//add individual pages to scrollView
page.frame = CGRect(origin: CGPoint(x:0 , y: CGFloat(pageIndex) * pageSize.height), size: pageSize)
scrollView.addSubview(page)
//add Subview with videoplayer frame to individual "pages"
let videoPlayerFrame = CGRect(x: 0, y: 0, width: pageSize.width, height: pageSize.height)
let videoPlayerView = PlayerView(frame: videoPlayerFrame)
page.addSubview(videoPlayerView)
}
//adjust size of scrollView
scrollView.contentSize = CGSize(width: pageSize.width, height: pageSize.height * CGFloat(numberOfPages))
}
Do you have an idea how to pause the video on the previous page? I would really appreciate your help!
There are about two ways I think you could do this depending on how exactly you want the video to stop.
Method 1: The Video on the Previous View Stops and the Video on the New View Does Not Auto-Play
You can create an Notification observer in each of the views which listens for a change of scroll view and fires a common function to stop the video (it is good practice to create a protocol to constrain these views to the common function although I will not do it here). So in each of the views you add an observer like this in the ViewDidLoad:
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.stopVideo), name: NSNotification.Name(rawValue: "stopVideo"), object: nil)
And you add the corresponding function that will be called in the view:
func stopVideo() {
self.player.stop()
}
Then in your scrollview, in the function you mentioned that "detects what page has appeared and also states the previous page", you fire a notification whenever the scroll view changes:
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "stopVideo"), object: nil)
This will stop any video playing in all the views whenever there is a change of view. P.S Also remember to remove the observer in the deinit()
Method 2: The Video on the Previous View Stops and the Video on the New View Does Will Auto-Play
If the previous video will stop and new video will auto-play you could make a protocol for each of the views that they must adhere to like so:
protocol StopVideoDelegate: class {
func stopVideo()
func startVideo()
}
Each of your views must implement this protocol, for example:
class ViewController1InScrollView: StopVideoDelegate {
func stopVideo() {
self.playerLayer.stop()
}
func startVideo() {
self.playerLayer.start()
}
}
Then in your scrollview, in the function you mentioned that "detects what page has appeared and also states the previous page", can simply cast the previous view and next view to the protocol type and call the corresponding function:
func theFunctionThatDetectsViewChangeInScrollView() {
nextView as! StopVideoDelegate
nextView.startVideo()
previousView as! StopVideoDelegate
previousView.stopVideo()
}

How can I mimic the bottom sheet from the Maps app?

Can anyone tell me how I can mimic the bottom sheet in the new Apple Maps app in iOS 10?
In Android, you can use a BottomSheet which mimics this behaviour, but I could not find anything like that for iOS.
Is that a simple scroll view with a content inset, so that the search bar is at the bottom?
I am fairly new to iOS programming so if someone could help me creating this layout, that would be highly appreciated.
This is what I mean by "bottom sheet":
I don't know how exactly the bottom sheet of the new Maps app, responds to user interactions. But you can create a custom view that looks like the one in the screenshots and add it to the main view.
I assume you know how to:
1- create view controllers either by storyboards or using xib files.
2- use googleMaps or Apple's MapKit.
Example
1- Create 2 view controllers e.g, MapViewController and BottomSheetViewController. The first controller will host the map and the second is the bottom sheet itself.
Configure MapViewController
Create a method to add the bottom sheet view.
func addBottomSheetView() {
// 1- Init bottomSheetVC
let bottomSheetVC = BottomSheetViewController()
// 2- Add bottomSheetVC as a child view
self.addChildViewController(bottomSheetVC)
self.view.addSubview(bottomSheetVC.view)
bottomSheetVC.didMoveToParentViewController(self)
// 3- Adjust bottomSheet frame and initial position.
let height = view.frame.height
let width = view.frame.width
bottomSheetVC.view.frame = CGRectMake(0, self.view.frame.maxY, width, height)
}
And call it in viewDidAppear method:
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
addBottomSheetView()
}
Configure BottomSheetViewController
1) Prepare background
Create a method to add blur and vibrancy effects
func prepareBackgroundView(){
let blurEffect = UIBlurEffect.init(style: .Dark)
let visualEffect = UIVisualEffectView.init(effect: blurEffect)
let bluredView = UIVisualEffectView.init(effect: blurEffect)
bluredView.contentView.addSubview(visualEffect)
visualEffect.frame = UIScreen.mainScreen().bounds
bluredView.frame = UIScreen.mainScreen().bounds
view.insertSubview(bluredView, atIndex: 0)
}
call this method in your viewWillAppear
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
prepareBackgroundView()
}
Make sure that your controller's view background color is clearColor.
2) Animate bottomSheet appearance
override func viewDidAppear(animated: Bool) {
super.viewDidAppear(animated)
UIView.animateWithDuration(0.3) { [weak self] in
let frame = self?.view.frame
let yComponent = UIScreen.mainScreen().bounds.height - 200
self?.view.frame = CGRectMake(0, yComponent, frame!.width, frame!.height)
}
}
3) Modify your xib as you want.
4) Add Pan Gesture Recognizer to your view.
In your viewDidLoad method add UIPanGestureRecognizer.
override func viewDidLoad() {
super.viewDidLoad()
let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
view.addGestureRecognizer(gesture)
}
And implement your gesture behaviour:
func panGesture(recognizer: UIPanGestureRecognizer) {
let translation = recognizer.translationInView(self.view)
let y = self.view.frame.minY
self.view.frame = CGRectMake(0, y + translation.y, view.frame.width, view.frame.height)
recognizer.setTranslation(CGPointZero, inView: self.view)
}
Scrollable Bottom Sheet:
If your custom view is a scroll view or any other view that inherits from, so you have two options:
First:
Design the view with a header view and add the panGesture to the header. (bad user experience).
Second:
1 - Add the panGesture to the bottom sheet view.
2 - Implement the UIGestureRecognizerDelegate and set the panGesture delegate to the controller.
3- Implement shouldRecognizeSimultaneouslyWith delegate function and disable the scrollView isScrollEnabled property in two case:
The view is partially visible.
The view is totally visible, the scrollView contentOffset property is 0 and the user is dragging the view downwards.
Otherwise enable scrolling.
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
let gesture = (gestureRecognizer as! UIPanGestureRecognizer)
let direction = gesture.velocity(in: view).y
let y = view.frame.minY
if (y == fullView && tableView.contentOffset.y == 0 && direction > 0) || (y == partialView) {
tableView.isScrollEnabled = false
} else {
tableView.isScrollEnabled = true
}
return false
}
NOTE
In case you set .allowUserInteraction as an animation option, like in the sample project, so you need to enable scrolling on the animation completion closure if the user is scrolling up.
Sample Project
I created a sample project with more options on this repo which may give you better insights about how to customise the flow.
In the demo, addBottomSheetView() function controls which view should be used as a bottom sheet.
Sample Project Screenshots
- Partial View
- FullView
- Scrollable View
Update iOS 15
In iOS 15, you can now use the native UISheetPresentationController.
if let sheet = viewControllerToPresent.sheetPresentationController {
sheet.detents = [.medium(), .large()]
// your sheet setup
}
present(viewControllerToPresent, animated: true, completion: nil)
Notice that you can even reproduce its navigation stack using the overcurrentcontext presentation mode:
let nextViewControllerToPresent: UIViewController = ...
nextViewControllerToPresent.modalPresentationStyle = .overCurrentContext
viewControllerToPresent.present(nextViewControllerToPresent, animated: true, completion: nil)
Legacy
I released a library based on my answer below.
It mimics the Shortcuts application overlay. See this article for details.
The main component of the library is the OverlayContainerViewController. It defines an area where a view controller can be dragged up and down, hiding or revealing the content underneath it.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Implement OverlayContainerViewControllerDelegate to specify the number of notches wished:
enum OverlayNotch: Int, CaseIterable {
case minimum, medium, maximum
}
func numberOfNotches(in containerViewController: OverlayContainerViewController) -> Int {
return OverlayNotch.allCases.count
}
func overlayContainerViewController(_ containerViewController: OverlayContainerViewController,
heightForNotchAt index: Int,
availableSpace: CGFloat) -> CGFloat {
switch OverlayNotch.allCases[index] {
case .maximum:
return availableSpace * 3 / 4
case .medium:
return availableSpace / 2
case .minimum:
return availableSpace * 1 / 4
}
}
SwiftUI (12/29/20)
A SwiftUI version of the library is now available.
Color.red.dynamicOverlay(Color.green)
Previous answer
I think there is a significant point that is not treated in the suggested solutions: the transition between the scroll and the translation.
In Maps, as you may have noticed, when the tableView reaches contentOffset.y == 0, the bottom sheet either slides up or goes down.
The point is tricky because we can not simply enable/disable the scroll when our pan gesture begins the translation. It would stop the scroll until a new touch begins. This is the case in most of the proposed solutions here.
Here is my try to implement this motion.
Starting point: Maps App
To start our investigation, let's visualize the view hierarchy of Maps (start Maps on a simulator and select Debug > Attach to process by PID or Name > Maps in Xcode 9).
It doesn't tell how the motion works, but it helped me to understand the logic of it. You can play with the lldb and the view hierarchy debugger.
Our view controller stacks
Let's create a basic version of the Maps ViewController architecture.
We start with a BackgroundViewController (our map view):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
We put the tableView in a dedicated UIViewController:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Now, we need a VC to embed the overlay and manage its translation.
To simplify the problem, we consider that it can translate the overlay from one static point OverlayPosition.maximum to another OverlayPosition.minimum.
For now it only has one public method to animate the position change and it has a transparent view:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Finally we need a ViewController to embed the all:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
In our AppDelegate, our startup sequence looks like:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
The difficulty behind the overlay translation
Now, how to translate our overlay?
Most of the proposed solutions use a dedicated pan gesture recognizer, but we actually already have one : the pan gesture of the table view.
Moreover, we need to keep the scroll and the translation synchronised and the UIScrollViewDelegate has all the events we need!
A naive implementation would use a second pan Gesture and try to reset the contentOffset of the table view when the translation occurs:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
But it does not work. The tableView updates its contentOffset when its own pan gesture recognizer action triggers or when its displayLink callback is called. There is no chance that our recognizer triggers right after those to successfully override the contentOffset.
Our only chance is either to take part of the layout phase (by overriding layoutSubviews of the scroll view calls at each frame of the scroll view) or to respond to the didScroll method of the delegate called each time the contentOffset is modified. Let's try this one.
The translation Implementation
We add a delegate to our OverlayVC to dispatch the scrollview's events to our translation handler, the OverlayContainerViewController :
protocol OverlayViewControllerDelegate: class {
func scrollViewDidScroll(_ scrollView: UIScrollView)
func scrollViewDidStopScrolling(_ scrollView: UIScrollView)
}
class OverlayViewController: UIViewController {
[...]
func scrollViewDidScroll(_ scrollView: UIScrollView) {
delegate?.scrollViewDidScroll(scrollView)
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
delegate?.scrollViewDidStopScrolling(scrollView)
}
}
In our container, we keep track of the translation using a enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
The current position calculation looks like :
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
We need 3 methods to handle the translation:
The first one tells us if we need to start the translation.
private func shouldTranslateView(following scrollView: UIScrollView) -> Bool {
guard scrollView.isTracking else { return false }
let offset = scrollView.contentOffset.y
switch overlayInFlightPosition {
case .maximum:
return offset < 0
case .minimum:
return offset > 0
case .progressing:
return true
}
}
The second one performs the translation. It uses the translation(in:) method of the scrollView's pan gesture.
private func translateView(following scrollView: UIScrollView) {
scrollView.contentOffset = .zero
let translation = translatedViewTargetHeight - scrollView.panGestureRecognizer.translation(in: view).y
translatedViewHeightContraint.constant = max(
Constant.minimumHeight,
min(translation, Constant.maximumHeight)
)
}
The third one animates the end of the translation when the user releases its finger. We calculate the position using the velocity & the current position of the view.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Our overlay's delegate implementation simply looks like :
class OverlayContainerViewController: UIViewController {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard shouldTranslateView(following: scrollView) else { return }
translateView(following: scrollView)
}
func scrollViewDidStopScrolling(_ scrollView: UIScrollView) {
// prevent scroll animation when the translation animation ends
scrollView.isEnabled = false
scrollView.isEnabled = true
animateTranslationEnd()
}
}
Final problem: dispatching the overlay container's touches
The translation is now pretty efficient. But there is still a final problem: the touches are not delivered to our background view. They are all intercepted by the overlay container's view.
We can not set isUserInteractionEnabled to false because it would also disable the interaction in our table view. The solution is the one used massively in the Maps app, PassThroughView:
class PassThroughView: UIView {
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let view = super.hitTest(point, with: event)
if view == self {
return nil
}
return view
}
}
It removes itself from the responder chain.
In OverlayContainerViewController:
override func loadView() {
view = PassThroughView()
}
Result
Here is the result:
You can find the code here.
Please if you see any bugs, let me know ! Note that your implementation can of course use a second pan gesture, specially if you add a header in your overlay.
Update 23/08/18
We can replace scrollViewDidEndDragging with
willEndScrollingWithVelocity rather than enabling/disabling the scroll when the user ends dragging:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
We can use a spring animation and allow user interaction while animating to make the motion flow better:
func moveOverlay(to position: OverlayPosition,
duration: TimeInterval,
velocity: CGPoint) {
overlayPosition = position
translatedViewHeightContraint.constant = translatedViewTargetHeight
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: velocity.y == 0 ? 1 : 0.6,
initialSpringVelocity: abs(velocity.y),
options: [.allowUserInteraction],
animations: {
self.view.layoutIfNeeded()
}, completion: nil)
}
Try Pulley:
Pulley is an easy to use drawer library meant to imitate the drawer
in iOS 10's Maps app. It exposes a simple API that allows you to use
any UIViewController subclass as the drawer content or the primary
content.
https://github.com/52inc/Pulley
I wrote my own library to achieve the intended behaviour in ios Maps app. It is a protocol oriented solution. So you don't need to inherit any base class instead create a sheet controller and configure as you wish. It also supports inner navigation/presentation with or without UINavigationController.
See below link for more details.
https://github.com/OfTheWolf/UBottomSheet
You can try my answer https://github.com/SCENEE/FloatingPanel. It provides a container view controller to display a "bottom sheet" interface.
It's easy to use and you don't mind any gesture recognizer handling! Also you can track a scroll view's(or the sibling view) in a bottom sheet if needed.
This is a simple example. Please note that you need to prepare a view controller to display your content in a bottom sheet.
import UIKit
import FloatingPanel
class ViewController: UIViewController {
var fpc: FloatingPanelController!
override func viewDidLoad() {
super.viewDidLoad()
fpc = FloatingPanelController()
// Add "bottom sheet" in self.view.
fpc.add(toParent: self)
// Add a view controller to display your contents in "bottom sheet".
let contentVC = ContentViewController()
fpc.set(contentViewController: contentVC)
// Track a scroll view in "bottom sheet" content if needed.
fpc.track(scrollView: contentVC.tableView)
}
...
}
Here is another example code to display a bottom sheet to search a location like Apple Maps.
iOS 15 in 2021 adds UISheetPresentationController, which is Apple's first public release of an Apple Maps-style "bottom sheet":
UISheetPresentationController
UISheetPresentationController lets you present your view controller as a sheet. Before you present your view controller, configure its sheet presentation controller with the behavior and appearance you want for your sheet.
Sheet presentation controllers specify a sheet's size based on a detent, a height where a sheet naturally rests. Detents allow a sheet to resize from one edge of its fully expanded frame while the other three edges remain fixed. You specify the detents that a sheet supports using detents, and monitor its most recently selected detent using selectedDetentIdentifier.
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
This new bottom sheet control is explored in WWDC Session 10063: Customize and Resize Sheets in UIKit
Unfortunately....
In iOS 15, the UISheetPresentationController has launched with only medium and large detents.
A small detent is notably absent from the iOS 15 API, which would be required to display an always-presented "collapsed" bottom sheet like Apple Maps:
Custom smaller Detents in UISheetPresentationController?
The medium detent was released to handle use cases such as the Share Sheet or the "••• More" menu in Mail: a button-triggered half sheet.
In iOS 15, Apple Maps is now using this UIKit sheet presentation rather than a custom implementation, which is a huge step in the right direction. Apple Maps in iOS 15 continues to show the "small" bar, as well as a 1/3rd height bar. But those view sizes are not public API available to developers.
UIKit engineers at the WWDC 2021 Labs seemed to know that a small detent would be a hugely popular UIKit component. I would expect to see an API expansion for iOS 16 next year.
We’ve just released a pure Swift Package supporting iOS 11.4+ which provides you a BottomSheet with theme and behavior options you can customize. This component is easy to use, and flexible. You can find it here: https://github.com/LunabeeStudio/LBBottomSheet.
A demo project is available in this repository too.
For example, it supports different ways to manage the needed height, and also adds to the controller behind it the ability to detect height changes and adapt its bottom content inset.
You can find more information on the GitHub repository and in the documentation: https://lbbottomsheet.lunabee.studio.
I think it can help you to do what you’re looking for. Don’t hesitate to tell me if you have comments/questions :)
Here you can see one of all the possible BottomSheet configurations:
**for iOS 15 Native Support available for this **
#IBAction func openSheet() {
let secondVC = self.storyboard?.instantiateViewController(withIdentifier: "SecondViewController")
// Create the view controller.
if #available(iOS 15.0, *) {
let formNC = UINavigationController(rootViewController: secondVC!)
formNC.modalPresentationStyle = UIModalPresentationStyle.pageSheet
guard let sheetPresentationController = formNC.presentationController as? UISheetPresentationController else {
return
}
sheetPresentationController.detents = [.medium(), .large()]
sheetPresentationController.prefersGrabberVisible = true
present(formNC, animated: true, completion: nil)
} else {
// Fallback on earlier versions
}
}
iOS 15 finally adds a native UISheetPresentationController!
Official documentation
https://developer.apple.com/documentation/uikit/uisheetpresentationcontroller
I recently created a component called SwipeableView as subclass of UIView, written in Swift 5.1 . It support all 4 direction, has several customisation options and can animate and interpolate different attributes and items ( such as layout constraints, background/tint color, affine transform, alpha channel and view center, all of them demoed with the respective show case ). It also supports the swiping coordination with the inner scroll view if set or auto detected. Should be pretty easy and straightforward to be used ( I hope 🙂)
Link at https://github.com/LucaIaco/SwipeableView
proof of concept:
Hope it helps
If you are looking for a SwiftUI 2.0 solution that uses View Struct, here it is:
https://github.com/kenfai/KavSoft-Tutorials-iOS/tree/main/MapsBottomSheet
Maybe you can try my answer https://github.com/AnYuan/AYPannel, inspired by Pulley. Smooth transition from moving the drawer to scrolling the list. I added a pan gesture on the container scroll view, and set shouldRecognizeSimultaneouslyWithGestureRecognizer to return YES. More detail in my github link above. Wish to help.

WKWebView, scrolling the web page to top

I tried this, which should work but doesn't:
webView.scrollView.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
I am working on a browser and have a couple of webViews in kept in stack. Some sites work as expected, start at the top, some do not.
Starts at top: http://leagueoflegends.com/
This one starts a little down, have to manually scroll up: http://euw.leagueoflegends.com/
Extra note, this problem never happens to the first webView. Even if I load the 2nd link and show it as first webView, it doesn't scroll to the middle.
I had a very similar problem when rotating my WKWebView.
After rotation to landscape the view is automatically scrolled to the middle.
I use following workaround to scroll to the top of the page again after rotation:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
coordinator.animate(alongsideTransition: nil, completion: { (context) in
self.webKitView.evaluateJavaScript("window.scrollTo(0,0)", completionHandler: nil)
})
}
In your case it should work to call this directly after you load your URL.
webKitView.evaluateJavaScript("window.scrollTo(0,0)", completionHandler: nil)
Or if your problem occurs when navigating within the WKWebView, you could use a WKNavigationDelegate (I have not tested this) :
override func viewDidLoad() {
super.viewDidLoad()
// load content of webKitView here and set as view or add as subview
webKitView.navigationDelegate = self
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webKitView.evaluateJavaScript("window.scrollTo(0,0)", completionHandler: nil)
}
Our app uses multiple WKWebView instances and we have also experienced this problem of some pages randomly scrolling to some location down the page.
We were able to work around the problem by having the next webView which will be loading the next page attached to the window hierarchy before loading the URL.
WKWebView* nextWebView = [WKWebView new];
[[[UIApplication sharedApplication] keyWindow] addSubview:webView.view];
[[[UIApplication sharedApplication] keyWindow] sendSubviewToBack:webView.view];
Then when you load your URL:
[nextWebView.view removeFromSuperview];
[controller.view addSubview: nextWebView.view];
I encountered this too. Finally, I fixed this way:
if #available(iOS 11, *) {
webView.scrollView.contentInsetAdjustmentBehavior = .never
}
If you have this issue on iOS 11, try this.
Before iOS 11.0, UIWebView was avilable with delegates to indicate that loading of webView is finished and we can do any operation after loading has been finished.
Now we have WKWebView which doesn't provide any delegate method to notify once your webView has finished loading. Due to this if we do following piece of code then it will not work:
let myURL = URL(string: "https://www.google.com")
let myRequest = URLRequest(url: myURL!)
webView.load(myRequest)
webView.scrollView.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
In this case, webView.load() is a asynchronous function which gets called and code moves further without waiting for its completion. Due to this your call for setContentOffset work but after that when webView finish loading it reset the ContentOffset to default, which is actually (0,0) but I am not sure why your WebView doesn't load from top. But doing code in following manner will may help you:
Extend to WKNavigationDelegate and implement didFinish navigation method in your ViewController and call setContentOffset in that method.
class ViewController: UIViewController, WKNavigationDelegate {
override func viewDidLoad() {
let myURL = URL(string: "https://www.google.com")
let myRequest = URLRequest(url: myURL!)
webView.load(myRequest)
webView.navigationDelegate = self
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.scrollView.setContentOffset(CGPoint(x: 0, y: 0), animated: false)
}
}
I would try to use javascript doc for scrolling to top:
NSString *script = #"window.scrollTo(0, 0)";
// UIWebView
// [webView stringByEvaluatingJavaScriptFromString:script];
// WKWebView
[webView evaluateJavaScript:script completionHandler:NULL];
Swift 4
This function is part of the WKWebView's UIScrollView and scrolls to a given rectangle.
scrollRectToVisible(_ rect: CGRect, animated: Bool)
A code example would look like:
let rect: CGRect = CGRect(x: 0, y: 0, width: 1, height: 1)
webView.scrollView.scrollRectToVisible(rect, animated: true)
This seems to happen if the WKWebView is not fully initialized or sized when asked to display a web page.
In my case, I created the web view during loadView in a UIViewController. If I then requested to navigate to a web page during viewDidLoad, the web view appeared scrolled partway down the page. I was able to fix the problem by moving the html request into viewWillAppear: (which is later in the view controller lifecycle).

CS193P Cassini code runs on simulator but crashes on device? "Message from debugger: Terminated due to Memory Error"

I'm going through Stanford's CS193P online course doing ios dev.
Lecture 9 deals with UIScrollView / delegation via simple url UIImage fetch app. Said app works perfectly fine in simulator but launches then crashes on live device (iPhone5) after trying to fetch an img with the following:
Message from debugger: Terminated due to Memory Error
I went back into my code, reread about delegation, searched SO (I found a similar thread, I made sure my project scheme does NOT have zombies enabled). I updated my device, my compiler / os, and am kinda bummed about what might be preventing this from running on the device...
The class example can be downloaded from Stanford at https://web.stanford.edu/class/cs193p/cgi-bin/drupal/system/files/sample_code/Cassini.zip but this code behaves the same way! This was originally written for ios 8.1 and we're at 8.4, are there any known issues?
code for the imageview controller:
import UIKit
class ImageViewController: UIViewController, UIScrollViewDelegate
{
// our Model
// publicly settable
// when it changes (but only if we are on screen)
// we'll fetch the image from the imageURL
// if we're off screen when this happens (view.window == nil)
// viewWillAppear will get it for us later
var imageURL: NSURL? {
didSet {
image = nil
if view.window != nil {
fetchImage()
}
}
}
// fetches the image at imageURL
// does so off the main thread
// then puts a closure back on the main queue
// to handle putting the image in the UI
// (since we aren't allowed to do UI anywhere but main queue)
private func fetchImage()
{
if let url = imageURL {
spinner?.startAnimating()
let qos = Int(QOS_CLASS_USER_INITIATED.value)
dispatch_async(dispatch_get_global_queue(qos, 0)) { () -> Void in
let imageData = NSData(contentsOfURL: url) // this blocks the thread it is on
dispatch_async(dispatch_get_main_queue()) {
// only do something with this image
// if the url we fetched is the current imageURL we want
// (that might have changed while we were off fetching this one)
if url == self.imageURL { // the variable "url" is capture from above
if imageData != nil {
// this might be a waste of time if our MVC is out of action now
// which it might be if someone hit the Back button
// or otherwise removed us from split view or navigation controller
// while we were off fetching the image
self.image = UIImage(data: imageData!)
} else {
self.image = nil
}
}
}
}
}
}
#IBOutlet private weak var spinner: UIActivityIndicatorView!
#IBOutlet private weak var scrollView: UIScrollView! {
didSet {
scrollView.contentSize = imageView.frame.size // critical to set this!
scrollView.delegate = self // required for zooming
scrollView.minimumZoomScale = 0.03 // required for zooming
scrollView.maximumZoomScale = 1.0 // required for zooming
}
}
// UIScrollViewDelegate method
// required for zooming
func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {
return imageView
}
private var imageView = UIImageView()
// convenience computed property
// lets us get involved every time we set an image in imageView
// we can do things like resize the imageView,
// set the scroll view's contentSize,
// and stop the spinner
private var image: UIImage? {
get { return imageView.image }
set {
imageView.image = newValue
imageView.sizeToFit()
scrollView?.contentSize = imageView.frame.size
spinner?.stopAnimating()
}
}
// put our imageView into the view hierarchy
// as a subview of the scrollView
// (will install it into the content area of the scroll view)
override func viewDidLoad() {
super.viewDidLoad()
scrollView.addSubview(imageView)
}
// for efficiency, we will only actually fetch the image
// when we know we are going to be on screen
override func viewWillAppear(animated: Bool) {
super.viewWillAppear(animated)
if image == nil {
fetchImage()
}
}
}
The source of the issue that decompression image from data (file format representative of image data) to screen can 'eat' a lot of memory.
Here is very good article about iOS image decompression -> Avoiding Image Decompression Sickness
Since all images in Cassini application are VERY large (wave_earth_mosaic_3.jpg (9999×9999), pia03883-full.jpg (14400×9600)) image decompression process 'eat' all phone memory. This leads to application crash.
To fix Cassini issue I modified code and added small function to lower images resolution by 2.
Here is code example (code fixed to Swift 2.0):
...
if imageData != nil {
// this might be a waste of time if our MVC is out of action now
// which it might be if someone hit the Back button
// or otherwise removed us from split view or navigation controller
// while we were off fetching the image
if let imageSource = UIImage(data: imageData!) {
self.image = self.imageResize(imageSource)
}
} else {
self.image = nil
}
...
func imageResize (imageOriginal:UIImage) -> UIImage {
let image = imageOriginal.CGImage
let width = CGImageGetWidth(image) / 2
let height = CGImageGetHeight(image) / 2
let bitsPerComponent = CGImageGetBitsPerComponent(image)
let bytesPerRow = CGImageGetBytesPerRow(image)
let colorSpace = CGImageGetColorSpace(image)
let bitmapInfo = CGImageGetBitmapInfo(image)
let context = CGBitmapContextCreate(nil, width, height, bitsPerComponent, bytesPerRow, colorSpace, bitmapInfo.rawValue)
CGContextSetInterpolationQuality(context, CGInterpolationQuality.High)
CGContextDrawImage(context, CGRect(origin: CGPointZero, size: CGSize(width: CGFloat(width), height: CGFloat(height))), image)
let scaledImage = UIImage(CGImage: CGBitmapContextCreateImage(context)!)
return scaledImage
}
So now application load all images without crash.
SWIFT 2.0 fix:
add this to Info.plist to allow HTTP loading
<key>NSAppTransportSecurity</key>
<dict>
<!--Include to allow all connections (DANGER)-->
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>

Resources