scenee / FloatingPanel

A clean and easy-to-use floating panel UI component for iOS
MIT License
5.61k stars 509 forks source link

SwiftUI Support/Example? #281

Open zntfdr opened 5 years ago

zntfdr commented 5 years ago

Update May 2021: Another approach is to use UITableView/UICollectionView and then embed the SwiftUI views as cells, Noah Gilmore has a great article showing how to do so.

Update May 2020: The code below now works for static content. I assume we shouldn't try to get the hidden UIKit scroll view used within SwiftUI, because that can easily break at any iOS update, therefore for the moment we should use components like VStack but not List or ScrollView.

I'm trying to make the library work with SwiftUI, and was wondering if you have had any success so far.

You can find my attempt here:

Steps:

  1. download FloatingPanel project
  2. open maps example, set target iOS 13
  3. open map ViewController.swift
  4. replace ViewController.swift content with:
    Click here to see the source code
import UIKit
import MapKit
import FloatingPanel
import SwiftUI

class ViewController: UIViewController, MKMapViewDelegate, UISearchBarDelegate, FloatingPanelControllerDelegate {
    var fpc: FloatingPanelController!
    var hostViewController: UIScrollViewController<MyList>!

    @IBOutlet weak var mapView: MKMapView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        // Initialize FloatingPanelController
        fpc = FloatingPanelController()
        fpc.delegate = self

        // Initialize FloatingPanelController and add the view
        fpc.surfaceView.backgroundColor = .white
        fpc.surfaceView.cornerRadius = 9.0
        fpc.surfaceView.shadowHidden = false

        hostViewController = UIScrollViewController(rootView: MyList())

        // Set a content view controller
        fpc.set(contentViewController: hostViewController)
        fpc.track(scrollView: hostViewController.scrollView)

        setupMapView()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        //  Add FloatingPanel to a view with animation.
        fpc.addPanel(toParent: self, animated: true)
    }

    func setupMapView() {
        let center = CLLocationCoordinate2D(latitude: 37.623198015869235,
                                            longitude: -122.43066818432008)
        let span = MKCoordinateSpan(latitudeDelta: 0.4425100023575723,
                                    longitudeDelta: 0.28543697435880233)
        let region = MKCoordinateRegion(center: center, span: span)
        mapView.region = region
        mapView.showsCompass = true
        mapView.showsUserLocation = true
        mapView.delegate = self
    }

    // MARK: FloatingPanelControllerDelegate

    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        switch newCollection.verticalSizeClass {
        case .compact:
            fpc.surfaceView.borderWidth = 1.0 / traitCollection.displayScale
            fpc.surfaceView.borderColor = UIColor.black.withAlphaComponent(0.2)
            return SearchPanelLandscapeLayout()
        default:
            fpc.surfaceView.borderWidth = 0.0
            fpc.surfaceView.borderColor = nil
            return nil
        }
    }
}

public class SearchPanelLandscapeLayout: FloatingPanelLayout {
    public var initialPosition: FloatingPanelPosition {
        .tip
    }

    public var supportedPositions: Set<FloatingPanelPosition> {
        [.full, .tip]
    }

    public func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
        case .full: return 16.0
        case .tip: return 69.0
        default: return nil
        }
    }

    public func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
        [
            surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0),
            surfaceView.widthAnchor.constraint(equalToConstant: 291),
        ]
    }

    public func backdropAlphaFor(position: FloatingPanelPosition) -> CGFloat {
        0
    }
}

struct MyList: View {
    var body: some View {
        VStack {
            ForEach((1...100).reversed(), id: \.self) { number in
                HStack {
                    Text("This is SwiftUI \(number)")
                    Spacer()
                }
                .padding()
            }
        }
    }
}

class UIScrollViewController<Content: View>: UIViewController {
    weak var scrollView: UIScrollView!
    private let hostingController: UIHostingController<Content>

    init(rootView: Content) {
        hostingController = UIHostingController<Content>(rootView: rootView)
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        // Add Scroll View.
        let uiScrollView = UIScrollView()
        self.scrollView = uiScrollView
        view = uiScrollView

        hostingController.view.translatesAutoresizingMaskIntoConstraints = false

        uiScrollView.addSubview(hostingController.view)
        let constraints = [
            hostingController.view.leadingAnchor.constraint(equalTo: uiScrollView.leadingAnchor),
            hostingController.view.trailingAnchor.constraint(equalTo: uiScrollView.trailingAnchor),
            hostingController.view.topAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.topAnchor),
            hostingController.view.bottomAnchor.constraint(equalTo: uiScrollView.contentLayoutGuide.bottomAnchor),
            hostingController.view.widthAnchor.constraint(equalTo: uiScrollView.widthAnchor)
        ]
        uiScrollView.addConstraints(constraints)

        super.viewDidLoad()
    }
}

Current working example:

Screen Recording 2020-05-01 at 10 44 03 AM 2020-05-01 10_52_29

Thank you in advance! Federico

scenee commented 5 years ago

Thank you for your attempt for Swift UI! I'm sorry I haven't yet tried the lib with SwiftUI because I'm working recently for v2 which beta I'm going to release next month.(v2 will resolve many issues in GitHub). But I would like to try your example and take a look at scroll tracking problem later.

zntfdr commented 5 years ago

Hi @SCENEE, thank you very much 😊

sipersso commented 4 years ago

@zntfdr Where you able to resolve this? I am facing the same issue here. @SCENEE is v2 still in pogress?

scenee commented 4 years ago

@sipersso Yes it is. But now I’m focusing to fix some issue on v1.x and I would like to release v2 on March.

zntfdr commented 4 years ago

@sipersso I've tried multiple ways, such as embedding SwiftUI views into auto-resizing UITableViewCells, so far no result was acceptable.

If you really want to use SwiftUI today, my suggestion is to implement this floating panel yourself (I haven't done it): with a geometry reader and a gesture recogniser you should be able to accomplish everything you need.

Thank you @SCENEE for all your support ❀️

sipersso commented 4 years ago

I agree with, it is a great library @SCENEE.

@zntfdr I did get scroll tracking to work with SwiftUI. The trick, which is missing from your example is to set the content size of the scrollView to the size of the hostingController view.

let width = UIScreen.main.bounds.width
let size = hostingController.view.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: size.height)
scrollView.addSubview(hostingController.view)
scrollView.contentSize = CGSize(width: width, height: size.height)

The downside is that if you the swiftui view is resized you would have to reset the contentsize and I am not sure how to get notified to do that. Other than that it seems to work pretty great and it does allow for interaction in SwiftUI as well as track scrolling.

I also did try to recreate the Panel in SwiftUI, but none of my attempts where nearly as good as the component that @SCENEE has built. Using the hybrid approach, UIKit panel, but SwiftUI content, makes it really easy to use from a UIKit UIViewController ;)

zntfdr commented 4 years ago

@sipersso

@zntfdr I did get scroll tracking to work with SwiftUI. The trick, which is missing from your example is to set the content size of the scrollView to the size of the hostingController view.

let width = UIScreen.main.bounds.width
let size = hostingController.view.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: size.height)
scrollView.addSubview(hostingController.view)
scrollView.contentSize = CGSize(width: width, height: size.height)

The downside is that if you the swiftui view is resized you would have to reset the contentsize and I am not sure how to get notified to do that. Other than that it seems to work pretty great and it does allow for interaction in SwiftUI as well as track scrolling.

Very cool! Many thanks @sipersso, I will experiment some more as soon as possible πŸ˜ƒ

I also did try to recreate the Panel in SwiftUI, but none of my attempts where nearly as good as the component that @SCENEE has built. Using the hybrid approach, UIKit panel, but SwiftUI content, makes it really easy to use from a UIKit UIViewController ;)

That's why I didn't try to reimplement the whole thing myself: I want to keep the feeling of a normal scroll view (with the iOS default pull elasticity, etc) πŸ‘

zntfdr commented 4 years ago

@sipersso I haven't managed to get the scroll tracking to work with SwiftUI, can you please share your code?

sipersso commented 4 years ago

I got ideas from this https://gist.github.com/timothycosta/0d8f64afeca0b6cc29665d87de0d94d2

But had to adapt it a little by setting the content size of the scrollview.

class  UIScrollViewViewController: UIViewController {

    lazy var scrollView: UIScrollView = {
        let v = UIScrollView()
        return v
    }()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(Text("Test")))

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.addSubview(self.scrollView)
        self.pinEdges(of: self.scrollView, to: self.view)
        self.hostingController.view.removeFromSuperview()
        self.hostingController.willMove(toParent: self)
        let width = UIScreen.main.bounds.width
        let size = hostingController.view.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
        hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: size.height)
        scrollView.addSubview(hostingController.view)
        scrollView.contentSize = CGSize(width: width, height: size.height)
        self.hostingController.didMove(toParent: self)
    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
            viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
            viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
            viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
            viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }

}

In my floatingpanelcontroller subclass I create an instance of my UIScrollViewViewController and then use this in ViewDidLoad

scrollViewViewController.hostingController = UIHostingController(rootView: AnyView(mySwiftuiView))
set(contentViewController: scrollViewViewController)
track(scrollView: scrollViewViewController.scrollView)

Note that this won't work if your swiftui view changes height. You would have to ajust the contentsize of the scrollview every time this happens.

zntfdr commented 4 years ago

Thank you @sipersso, I still can't get the scroll tracking even with your code snippets: but it might be me just getting frustrated and giving up too early 😝

Might try again in a few days πŸ‘

A full working example similar to the one I posted on the first post would be greatly appreciated πŸš€

sipersso commented 4 years ago

Hi @zntfdr! Unfortunately I can't share much more than I already have. The rest of my code is non-generic and would be quite an effort for me to provide a more detailed/standalone example

zntfdr commented 4 years ago

No worries @sipersso, many thanks for the support! πŸ€—

ramunasjurgilas commented 4 years ago

@zntfdr Here is working example:


import UIKit
import MapKit
import FloatingPanel
import SwiftUI

class MyFpc: FloatingPanelController {
    var scrollViewViewController = UIScrollViewViewController()

    override func viewDidLoad() {
        super.viewDidLoad()
        scrollViewViewController.hostingController = UIHostingController(rootView: vvv)
        set(contentViewController: scrollViewViewController)
        track(scrollView: scrollViewViewController.scrollView)
    }

    var vvv: AnyView {
        let result1 = VStack {
            Text("flk")
            Text("flk")
            Text("flk")
            Text("flk")
            List {
                Text("flk")
                Text("flk")
                Text("flk")
                Text("flk")
            }
        }
        let result = List {
          ForEach((1...100).reversed(), id: \.self) {
              Text("\($0)")
          }
        }
        .frame(width: 100, height: 800, alignment: .center)
        return AnyView(result)
    }
}

class ViewController: UIViewController, MKMapViewDelegate, UISearchBarDelegate, FloatingPanelControllerDelegate {
    var fpc: MyFpc!
    var hostViewController: UIScrollViewController<MyList>!

    @IBOutlet weak var mapView: MKMapView!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
        // Initialize FloatingPanelController
        fpc = MyFpc()
        fpc.delegate = self

        // Initialize FloatingPanelController and add the view
        fpc.surfaceView.backgroundColor = .clear
        if #available(iOS 11, *) {
            fpc.surfaceView.cornerRadius = 9.0
        } else {
            fpc.surfaceView.cornerRadius = 0.0
        }
        fpc.surfaceView.shadowHidden = false

        hostViewController = UIScrollViewController(rootView: MyList())

        // Set a content view controller
        fpc.set(contentViewController: hostViewController)
        fpc.track(scrollView: hostViewController.scrollView)

        setupMapView()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        //  Add FloatingPanel to a view with animation.
        fpc.addPanel(toParent: self, animated: true)
    }

    func setupMapView() {
        let center = CLLocationCoordinate2D(latitude: 37.623198015869235,
                                            longitude: -122.43066818432008)
        let span = MKCoordinateSpan(latitudeDelta: 0.4425100023575723,
                                    longitudeDelta: 0.28543697435880233)
        let region = MKCoordinateRegion(center: center, span: span)
        mapView.region = region
        mapView.showsCompass = true
        mapView.showsUserLocation = true
        mapView.delegate = self
    }

    // MARK: FloatingPanelControllerDelegate

    func floatingPanel(_ vc: FloatingPanelController, layoutFor newCollection: UITraitCollection) -> FloatingPanelLayout? {
        switch newCollection.verticalSizeClass {
        case .compact:
            fpc.surfaceView.borderWidth = 1.0 / traitCollection.displayScale
            fpc.surfaceView.borderColor = UIColor.black.withAlphaComponent(0.2)
            return SearchPanelLandscapeLayout()
        default:
            fpc.surfaceView.borderWidth = 0.0
            fpc.surfaceView.borderColor = nil
            return nil
        }
    }
}

public class SearchPanelLandscapeLayout: FloatingPanelLayout {
    public var initialPosition: FloatingPanelPosition {
        return .tip
    }

    public var supportedPositions: Set<FloatingPanelPosition> {
        return [.full, .tip]
    }

    public func insetFor(position: FloatingPanelPosition) -> CGFloat? {
        switch position {
        case .full: return 16.0
        case .tip: return 69.0
        default: return nil
        }
    }

    public func prepareLayout(surfaceView: UIView, in view: UIView) -> [NSLayoutConstraint] {
        if #available(iOS 11.0, *) {
            return [
                surfaceView.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor, constant: 8.0),
                surfaceView.widthAnchor.constraint(equalToConstant: 291),
            ]
        } else {
            return [
                surfaceView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 8.0),
                surfaceView.widthAnchor.constraint(equalToConstant: 291),
            ]
        }
    }

    public func backdropAlphaFor(position: FloatingPanelPosition) -> CGFloat {
        return 0.0
    }
}

struct MyList: View {
  var body: some View {
    List {
      ForEach((1...100).reversed(), id: \.self) {
          Text("\($0)")
      }
    }
  }
}

class UIScrollViewController<Content: View>: UIViewController {
  weak var scrollView: UIScrollView!
  private let hostingController: UIHostingController<Content>

  init(rootView: Content) {
    hostingController = UIHostingController<Content>(rootView: rootView)
    super.init(nibName: nil, bundle: nil)
  }

  @available(*, unavailable)
  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    // Add Scroll View.
    let scrollView = UIScrollView()
    self.scrollView = scrollView
    view = scrollView

    scrollView.addSubview(hostingController.view)
    hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
    // If we un-comment the following line, fpc will correctly track the
    // `scrollView`, but no iteraction would possible with SwiftUI's `View`.
    // hostingController.view.isUserInteractionEnabled = false

    super.viewDidLoad()
  }
}

class  UIScrollViewViewController: UIViewController {

    lazy var scrollView: UIScrollView = {
        let v = UIScrollView()
        return v
    }()

    var hostingController: UIHostingController<AnyView> = UIHostingController(rootView: AnyView(Text("Test")))

    override func viewDidLoad() {
        super.viewDidLoad()

        self.view.addSubview(self.scrollView)
        self.pinEdges(of: self.scrollView, to: self.view)
        self.hostingController.view.removeFromSuperview()
        self.hostingController.willMove(toParent: self)
        let width = UIScreen.main.bounds.width
        let size = hostingController.view.sizeThatFits(CGSize(width: width, height: CGFloat.greatestFiniteMagnitude))
        hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: size.height)
        scrollView.addSubview(hostingController.view)
        scrollView.contentSize = CGSize(width: width, height: size.height)
        self.hostingController.didMove(toParent: self)
    }

    func pinEdges(of viewA: UIView, to viewB: UIView) {
        viewA.translatesAutoresizingMaskIntoConstraints = false
        viewB.addConstraints([
            viewA.leadingAnchor.constraint(equalTo: viewB.leadingAnchor),
            viewA.trailingAnchor.constraint(equalTo: viewB.trailingAnchor),
            viewA.topAnchor.constraint(equalTo: viewB.topAnchor),
            viewA.bottomAnchor.constraint(equalTo: viewB.bottomAnchor),
        ])
    }

}
zntfdr commented 4 years ago

Many thanks @ramunasjurgilas πŸ˜ƒ Will check it out over the weekend! πŸš€

scenee commented 4 years ago

Thank you so much for your work, @zntfdr, @sipersso and @ramunasjurgila πŸ‘πŸš€ Unfortunately now I don't have enough time to take care of this, but I would do it after releasing v2 😌

zntfdr commented 4 years ago

@ramunasjurgilas I'm afraid that your example doesn't work.

As you can see from the video below, scrolling is tracked only when the scroll is outside of the SwiftUI view.

Screen Recording 2020-02-22 at 3 09 23 PM 2020-02-22 15_11_02

Thank you for trying! 😊

sipersso commented 4 years ago

@zntfdr just checked the example you provided. You are using List and this has it's own scrollview, which explains why the scrolltracking isn't working. Try using a VStack instead in SwiftUI.

zntfdr commented 3 years ago

Update: I've added a SwiftUI implementation in #481.

While the example shows a mix of UIKit and SwiftUI, if you use something like SwiftUI-Introspect, you can build your panel content entirely in SwiftUI, e.g.:

ContentView()
  .floatingPanel { proxy in
    ScrollView {
      // .. your content here.
    }
    .introspectScrollView { scrollView in
      proxy.track(scrollView: scrollView)
    }
  }

If you'd like to try it out in your app, copy the FloatingPanel group from the example into your project, and you'll be able to use it as if it was part of the FloatingPanel library:

Screen Shot 2021-07-25 at 17 32 51

Please try it out and let me know how it goes πŸ˜ƒ

leonboe1 commented 1 year ago

For me, this does not work if the content height is dynamic. Anyone else has the same problem?