マップアプリのボトムシートを模倣するにはどうすればよいですか?


202

iOS 10の新しいマップアプリのボトムシートを模倣する方法を誰かに教えてもらえますか?

Androidでは、BottomSheetこの動作を模倣するを使用できますが、iOSの場合、そのようなものは見つかりませんでした。

検索バーが下部にあるように、コンテンツがはめ込まれた単純なスクロールビューですか?

私はiOSプログラミングにかなり慣れていないので、誰かがこのレイアウトの作成を手伝ってくれるとしたら、それは高く評価されます。

これが「ボトムシート」の意味です。

マップで折りたたまれたボトムシートのスクリーンショット

マップで展開されたボトムシートのスクリーンショット


1
自分で作成する必要があります。これは、背景がぼやけており、ジェスチャー認識機能がいくつかあるビューです。
Khanh Nguyen

@KhanhNguyenええ、私はそれを自分自身を構築する必要が知っているが、私は思ったんだけどと、ビューはこの効果をarchieveするために使用されているとどのように順序付けられてというように
QWERTZ

ぼかし効果のある視覚効果ビューです。このSOの質問を
Khanh Nguyen

1
パンジェスチャ認識機能を下部のビューに追加し、それに応じてビューを移動できると思います(ジェスチャ認識機能のイベント間のy座標を保存して変更を観察し、差を計算します)。
カーングエン

2
私はあなたの質問が好きです。このようなものを実装する予定です。誰かがいくつかの例を書くことができればいいでしょう。
wviana 2016年

回答:


330

新しいマップアプリの一番下のシートがユーザーの操作にどのように反応するのか正確にはわかりません。しかし、スクリーンショットのようなカスタムビューを作成して、メインビューに追加することができます。

私はあなたが次の方法を知っていると思います:

1-ストーリーボードまたはxibファイルを使用して、ビューコントローラーを作成します。

2- googleMapsまたはAppleのMapKitを使用します。

1- MapViewControllerBottomSheetViewControllerなどの2つのビューコントローラを作成します。。最初のコントローラーはマップをホストし、2番目は下部シート自体です。

MapViewControllerを構成する

下部シートビューを追加するメソッドを作成します。

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)
}

そして、それをviewDidAppearメソッドで呼び出します。

override func viewDidAppear(animated: Bool) {
    super.viewDidAppear(animated)
    addBottomSheetView()
}

BottomSheetViewControllerを構成する

1)背景を準備する

ぼかしとバイブレーション効果を追加するメソッドを作成する

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)
}

このメソッドをviewWillAppearで呼び出します

override func viewWillAppear(animated: Bool) {
   super.viewWillAppear(animated)
   prepareBackgroundView()
}

コントローラのビューの背景色がclearColorであることを確認してください。

2)ボトムシートの外観をアニメーション化する

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)必要に応じてxibを変更します。

4)Pan Gesture Recognizerをビューに追加します。

viewDidLoadメソッドにUIPanGestureRecognizerを追加します。

override func viewDidLoad() {
    super.viewDidLoad()

    let gesture = UIPanGestureRecognizer.init(target: self, action: #selector(BottomSheetViewController.panGesture))
    view.addGestureRecognizer(gesture)

}

そして、ジェスチャー動作を実装します。

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)
}

スクロール可能なボトムシート:

カスタムビューがスクロールビューまたは継承元のその他のビューの場合、次の2つのオプションがあります。

最初:

ヘッダービューを使用してビューをデザインし、panGestureをヘッダーに追加します。(悪いユーザー体験)

第二:

1-下部のシートビューにpanGestureを追加します。

2- UIGestureRecognizerDelegateを実装し、panGestureデリゲートをコントローラーに設定します。

3- shouldRecognizeSimultaneouslyWithデリゲート関数を実装し、2つのケースでscrollView isScrollEnabledプロパティを無効にします。

  • ビューは部分的に表示されています。
  • ビューは完全に表示され、scrollView contentOffsetプロパティは0で、ユーザーはビューを下にドラッグしています。

それ以外の場合は、スクロールを有効にします。

  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
  }

注意

サンプルプロジェクトのように、.allowUserInteractionをアニメーションオプションとして設定した場合、ユーザーが上にスクロールしている場合は、アニメーション完了クロージャーでスクロールを有効にする必要があります。

サンプルプロジェクト

このリポジトリでより多くのオプションを使用してサンプルプロジェクトを作成しました。フローをカスタマイズする方法についてより良い洞察を得ることができます。

デモでは、addBottomSheetView()関数が、ボトムシートとして使用するビューを制御します。

サンプルプロジェクトのスクリーンショット

-パーシャルビュー

ここに画像の説明を入力してください

- 全景

ここに画像の説明を入力してください

-スクロール可能なビュー

ここに画像の説明を入力してください


3
これは実際には、childViewsとsubViewsに適したチュートリアルです。ありがとうございます:)
Edison

@AhmedElassuty xibにtableviewを追加してボトムシートとしてロードするにはどうすればよいですか?
nikhil nangia 16

3
@nikhilnangia tableViewを含む別のviewController "ScrollableBottomSheetViewController.swift"でリポを更新しました。できるだけ早く回答を更新します。これも確認してくださいgithub.com/AhmedElassuty/IOS-BottomSheet/issues/1
Ahmad Elassuty

12
Apple Mapsのボトムシートは、シートのドラッグジェスチャーからスクロールビューのスクロールジェスチャーへの移行を1つのスムーズな動きで処理することに気付きました。あなたの例はこれをしません。それがどのように達成されるかについて、何か考えはありますか?ありがとう、そして例をリポジトリに投稿してくれてありがとう!
停止

2
@RafaelaLourenço私の答えが役に立てて本当に嬉しいです!ありがとうございました!
Ahmad Elassuty

55

以下の回答に基づいてライブラリをリリースしました。

これは、ショートカットアプリケーションオーバーレイを模倣しています。詳細については、この記事を参照してください。

ライブラリの主なコンポーネントはOverlayContainerViewControllerです。これは、ビューコントローラーを上下にドラッグして、その下のコンテンツを非表示または表示できる領域を定義します。

let contentController = MapsViewController()
let overlayController = SearchViewController()

let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
    contentController,
    overlayController
]

window?.rootViewController = containerController

OverlayContainerViewControllerDelegate希望するノッチの数を指定するために実装します。

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
    }
}

前の答え

提案された解決策では扱われない重要な点があると思います。スクロールと翻訳の間の移行です。

スクロールと翻訳の間のマップ遷移

マップでは、お気づきかもしれませんが、tableViewがに達するcontentOffset.y == 0と、下のシートが上または下にスライドします。

パンジェスチャが翻訳を開始するときにスクロールを有効または無効にすることはできないため、要点は注意が必要です。新しいタッチが始まるまでスクロールを停止します。これは、ここで提案されているソリューションのほとんどに当てはまります。

これが、この動きを実装するための私の試みです。

出発点:マップアプリ

私たちの調査を開始するには、聞かせてのは、地図のビュー階層を可視化する(シミュレータ上で地図を起動し、選択Debug> Attach to process by PID or Name> MapsXcodeの9)。

マップのデバッグビュー階層

モーションの仕組みはわかりませんが、そのロジックを理解するのに役立ちました。lldbとビュー階層デバッガーで遊ぶことができます。

View Controllerスタック

Maps ViewControllerアーキテクチャの基本バージョンを作成してみましょう。

BackgroundViewController(私たちのマップビュー)から始めます:

class BackgroundViewController: UIViewController {
    override func loadView() {
        view = MKMapView()
    }
}

tableViewを専用に配置しますUIViewController

class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {

    lazy var tableView = UITableView()

    override func loadView() {
        view = tableView
        tableView.dataSource = self
        tableView.delegate = self
    }

    [...]
}

ここで、オーバーレイを埋め込み、その変換を管理するVCが必要です。問題を単純化するために、オーバーレイをある静的ポイントOverlayPosition.maximumから別の静的ポイントに変換できると考えていますOverlayPosition.minimum

現時点では、位置の変化をアニメーション化するパブリックメソッドが1つだけあり、透過的なビューがあります。

enum OverlayPosition {
    case maximum, minimum
}

class OverlayContainerViewController: UIViewController {

    let overlayViewController: OverlayViewController
    var translatedViewHeightContraint = ...

    override func loadView() {
        view = UIView()
    }

    func moveOverlay(to position: OverlayPosition) {
        [...]
    }
}

最後に、すべてを埋め込むViewControllerが必要です。

class StackViewController: UIViewController {

    private var viewControllers: [UIViewController]

    override func viewDidLoad() {
        super.viewDidLoad()
        viewControllers.forEach { gz_addChild($0, in: view) }
    }
}

AppDelegateでは、起動シーケンスは次のようになります。

let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])

オーバーレイ翻訳の難しさ

では、オーバーレイを翻訳するにはどうすればよいですか?

提案されたソリューションのほとんどは、専用のパンジェスチャーレコグナイザーを使用しますが、実際には、テーブルビューのパンジェスチャーを既に使用しています。さらに、スクロールと翻訳の同期を維持する必要があり、必要なUIScrollViewDelegateすべてのイベントがあります。

単純な実装では、2番目のパンジェスチャーを使用しcontentOffset、変換が発生したときにテーブルビューのをリセットしようとします。

func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
    if isTranslating {
        tableView.contentOffset = .zero
    }
}

しかし、それは機能しません。tableViewはcontentOffset、独自のパンジェスチャーレコグナイザーアクションがトリガーされたとき、またはそのdisplayLinkコールバックが呼び出されたときに更新します。それらの直後に認識エンジンがトリガーして、をオーバーライドする可能性はありませんcontentOffset。唯一のチャンスは、レイアウト段階(layoutSubviewsスクロールビューの各フレームでスクロールビューの呼び出しをオーバーライドする)に参加するかdidScrollcontentOffsetが変更されるたびに呼び出されるデリゲートのメソッドに応答することです。これを試してみましょう。

翻訳の実装

にデリゲートを追加してOverlayVC、scrollviewのイベントを翻訳ハンドラである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)
    }
}

コンテナーでは、列挙型を使用して翻訳を追跡します。

enum OverlayInFlightPosition {
    case minimum
    case maximum
    case progressing
}

現在の位置の計算は次のようになります。

private var overlayInFlightPosition: OverlayInFlightPosition {
    let height = translatedViewHeightContraint.constant
    if height == maximumHeight {
        return .maximum
    } else if height == minimumHeight {
        return .minimum
    } else {
        return .progressing
    }
}

翻訳を処理するには3つのメソッドが必要です。

最初のものは、翻訳を開始する必要があるかどうかを教えてくれます。

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
    }
}

2つ目は変換を実行します。translation(in:)scrollViewのパンジェスチャのメソッドを使用します。

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)
    )
}

3つ目は、ユーザーが指を離したときに、翻訳の終了をアニメーション化します。ビューの速度と現在の位置を使用して位置を計算します。

private func animateTranslationEnd() {
    let position: OverlayPosition =  // ... calculation based on the current overlay position & velocity
    moveOverlay(to: position)
}

オーバーレイのデリゲート実装は次のようになります。

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()
    }
}

最後の問題:オーバーレイコンテナーのタッチのディスパッチ

翻訳はかなり効率的になりました。しかし、まだ最後の問題があります。タッチがバックグラウンドビューに配信されません。これらはすべて、オーバーレイコンテナーのビューによってインターセプトされます。に設定isUserInteractionEnabledするfalseことはできません。テーブルビューでの相互作用も無効になるからです。解決策は、マップアプリで大量に使用されるものです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
    }
}

レスポンダーチェーンから自分自身を削除します。

OverlayContainerViewController

override func loadView() {
    view = PassThroughView()
}

結果

結果は次のとおりです。

結果

ここでコードを見つけることができます。

バグを見つけたらお知らせください。もちろん、特にオーバーレイにヘッダーを追加する場合は、実装で2番目のパンジェスチャーを使用できます。

更新23/08/18

ユーザーがドラッグを終了したときに、スクロールscrollViewDidEndDraggingwillEndScrollingWithVelocityはなくenabling/で置き換えることができdisablingます。

func scrollView(_ scrollView: UIScrollView,
                willEndScrollingWithVelocity velocity: CGPoint,
                targetContentOffset: UnsafeMutablePointer<CGPoint>) {
    switch overlayInFlightPosition {
    case .maximum:
        break
    case .minimum, .progressing:
        targetContentOffset.pointee = .zero
    }
    animateTranslationEnd(following: scrollView)
}

スプリングアニメーションを使用して、アニメーションしながらユーザーインタラクションを可能にし、モーションフローを改善することができます。

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)
}

このアプローチは、アニメーションの段階ではインタラクティブではありません。たとえば、マップでは、シートが拡大するときに指でシートをキャッチできます。その後、上下にパンしてアニメーションをスクラブできます。最も近いスポットに戻ります。これはUIViewPropertyAnimator(一時停止する機能がある)を使用して解決でき、fractionCompleteプロパティを使用してスクラブを実行できると思います。
18

1
@austのとおりです。以前の変更をプッシュするのを忘れました。今は良いはずです。ありがとう。
GaétanZ

これは素晴らしいアイデアですが、すぐに問題が1つ見られます。このtranslateView(following:)方法では、平行移動に基づいて高さを計算しますが、0.0以外の値から開始する可能性があります(たとえば、ドラッグすると、テーブルビューが少しスクロールされ、オーバーレイが最大化されます)。それは最初のジャンプになります。これを解決するには、テーブルビューのscrollViewWillBeginDragging初期値contentOffsetを覚えておき、それをtranslateView(following:)の計算で使用します。
JakubTruhlář18年

1
@GaétanZSimulatorのMaps.appにもデバッガをアタッチすることに興味がありましたが、「Could not attach to pid:“ 9276”」に続いて「Ensure“ Maps” is not running running、and <username> has permission toデバッグしてください。」-どのようにしてXcodeをだましてMaps.appにアタッチできるようにしましたか?
matm

1
@matm @GaétanZはXcode 10.1 / Mojaveで動作しました->システムインテグリティ保護(SIP)を無効にするには、無効にする必要があります。次のことを行う必要があります。1)Macを再起動します。2)cmd + Rを押したままにします。3)メニューから、[ユーティリティ]、[ターミナル]の順に選択します。4)ターミナルウィンドウで次のように入力しますcsrutil disable && reboot。これで、Appleプロセスにアタッチできることを含め、rootが通常提供するすべての権限を取得できます。
valdyr

18

プーリーを試してください:

Pulleyは、iOS 10のマップアプリで引き出しを模倣することを目的とした使いやすい引き出しライブラリです。これは、任意のUIViewControllerサブクラスをドロワーコンテンツまたはプライマリコンテンツとして使用できるようにする単純なAPIを公開します。

プーリープレビュー

https://github.com/52inc/Pulley


1
基になるリストでのスクロールをサポートしていますか?
Richard Lindhout 2017

@RichardLindhout-デモを実行すると、スクロールがサポートされているように見えますが、ドロワーの移動からリストのスクロールへのスムーズな移行はサポートされていません。
停止

この例では、左下のAppleの法的リンクが非表示になっています。
Gerd Castan

1
非常に簡単な質問です。どのようにしてボトムシートの上部にインジケーターを表示しましたか?
イスラフィル

12

私の答えhttps://github.com/SCENEE/FloatingPanelを試すことができます。「ボトムシート」インターフェイスを表示するためのコンテナービューコントローラーを提供します。

使い方は簡単で、ジェスチャレコグナイザの処理を気にする必要はありません。また、必要に応じて、下部シートのスクロールビュー(または兄弟ビュー)を追跡できます。

これは簡単な例です。下部のシートにコンテンツを表示するには、View Controllerを準備する必要があることに注意してください。

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)    
    }
    ...
}

以下は、Apple Mapsなどの場所を検索するための下部シートを表示する別のコード例です。

Apple Mapsのようなサンプルアプリ


fpc.set(contentViewController:newsVC)、ライブラリにメソッドがない
Faris Muhammed

1
非常に簡単な質問です。どのようにしてボトムシートの上部にインジケーターを表示しましたか?
イスラフィル

1
@AIsrafilそれは単なるUIViewだと思います
ShadeToD

私はそれは本当に素晴らしい解決策だと思いますが、このフローティングパネルでモーダルビューコントローラーを提示することでiOS 13に問題があります。
ShadeToD

12

iOSマップアプリで意図した動作を実現するために、独自のライブラリを作成しました。プロトコル指向のソリューションです。したがって、基本クラスを継承する必要はなく、代わりにシートコントローラを作成して、必要に応じて構成します。また、UINavigationControllerの有無にかかわらず、内部ナビゲーション/プレゼンテーションをサポートします。

詳細については、以下のリンクを参照してください。

https://github.com/OfTheWolf/UBottomSheet

ubottomシート


ポップアップの背後にあるVCをクリックすることができるので、これが選択された答えになるはずです。
ジェイ

非常に簡単な質問です。どのようにしてボトムシートの上部にインジケーターを表示しましたか?
イスラフィル

1

私は最近、Swift 5.1で書かSwipeableViewれたのサブクラスと呼ばれるコンポーネントを作成しましたUIView。4つの方向すべてをサポートし、いくつかのカスタマイズオプションがあり、さまざまな属性とアイテム(レイアウト制約、背景/ティントカラー、アフィン変換、アルファチャネル、ビューセンターなど)をアニメーション化および補間できます。これらはすべて、それぞれのショーケースでデモされます。また、設定または自動検出された場合は、内側のスクロールビューとのスワイプ調整をサポートします。使用するのは非常に簡単で簡単でなければなりません(私はhopeを願っています)

https://github.com/LucaIaco/SwipeableViewのリンク

コンセプトの証明:

ここに画像の説明を入力してください

それが役に立てば幸い


0

たぶん、あなたは私の答えを試してみることができますhttps://github.com/AnYuan/AYPannel、プーリーに触発されました。引き出しの移動からリストのスクロールへのスムーズな移行。コンテナースクロールビューにパンジェスチャーを追加し、shouldRecognizeSimultaneouslyWithGestureRecognizerをYESに設定しました。上記の私のgithubリンクの詳細。助けて欲しい。


6
このリンクで質問に答えることができますが、回答の重要な部分をここに含め、参照用のリンクを提供することをお勧めします。リンクされたページが変更されると、リンクのみの回答が無効になる可能性があります。このリンクで質問に答えることができますが、回答の重要な部分をここに含め、参照用のリンクを提供することをお勧めします。リンクされたページが変更されると、リンクのみの回答が無効になる可能性があります。
ピロー2017
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.