Saya merilis perpustakaan berdasarkan jawaban saya di bawah ini.
Ini meniru overlay aplikasi Shortcuts. Lihat artikel ini untuk detailnya.
Komponen utama perpustakaan adalah OverlayContainerViewController
. Ini mendefinisikan area di mana pengontrol tampilan dapat diseret ke atas dan ke bawah, menyembunyikan atau mengungkapkan konten di bawahnya.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Terapkan OverlayContainerViewControllerDelegate
untuk menentukan jumlah takik yang diinginkan:
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
}
}
Jawaban sebelumnya
Saya pikir ada poin penting yang tidak diperlakukan dalam solusi yang disarankan: transisi antara gulungan dan terjemahan.
Di Maps, seperti yang mungkin telah Anda perhatikan, ketika tableView mencapai contentOffset.y == 0
, lembar bawah akan naik atau turun.
Intinya rumit karena kita tidak bisa begitu saja mengaktifkan / menonaktifkan gulir ketika gerakan pan kami memulai terjemahan. Itu akan menghentikan gulir sampai sentuhan baru dimulai. Ini adalah kasus di sebagian besar solusi yang diusulkan di sini.
Ini adalah usaha saya untuk mengimplementasikan gerakan ini.
Titik awal: Aplikasi Maps
Untuk memulai penyelidikan kami, mari kita memvisualisasikan tampilan hirarki Maps (mulai Maps di simulator dan pilih Debug
> Attach to process by PID or Name
> Maps
di Xcode 9).
Itu tidak mengatakan bagaimana gerakan itu bekerja, tetapi itu membantu saya untuk memahami logika itu. Anda dapat bermain dengan lldb dan debugger hierarki tampilan.
Tumpukan pengontrol tampilan kami
Mari kita buat versi dasar arsitektur Maps ViewController.
Kami mulai dengan BackgroundViewController
(tampilan peta kami):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Kami menempatkan tableView dalam dedicated UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Sekarang, kita membutuhkan VC untuk menyematkan overlay dan mengelola terjemahannya. Untuk menyederhanakan masalah, kami menganggapnya dapat menerjemahkan overlay dari satu titik statis OverlayPosition.maximum
ke titik lainnyaOverlayPosition.minimum
.
Untuk saat ini ia hanya memiliki satu metode publik untuk menghidupkan perubahan posisi dan memiliki pandangan transparan:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Akhirnya kita membutuhkan ViewController untuk menanamkan semuanya:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
Di AppDelegate kami, urutan startup kami terlihat seperti:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
Kesulitan di balik terjemahan overlay
Sekarang, bagaimana cara menerjemahkan overlay kami?
Sebagian besar solusi yang diajukan menggunakan pengenal isyarat pan khusus, tetapi kami sebenarnya sudah memilikinya: isyarat pan dari tampilan tabel. Selain itu, kita perlu menjaga agar gulungan dan terjemahannya tetap tersinkronisasi dan UIScrollViewDelegate
memiliki semua acara yang kita butuhkan!
Implementasi naif akan menggunakan Gesture pan kedua dan mencoba untuk mereset contentOffset
tampilan tabel ketika terjemahan terjadi:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Tapi itu tidak berhasil. TableView memperbaruicontentOffset
ketika tindakan pengidentifikasi gerakan pan sendiri memicu atau ketika panggilan balik displayLink disebut. Tidak ada peluang yang diakui pemicu kami tepat setelah mereka berhasil menimpa contentOffset
. Satu-satunya kesempatan kami adalah mengambil bagian dari fase tata letak (dengan mengesampingkan layoutSubviews
panggilan tampilan gulir di setiap bingkai tampilan gulir) atau untuk menanggapi didScroll
metode delegasi yang dipanggil setiap kali contentOffset
diubah. Ayo coba yang ini.
Implementasi terjemahan
Kami menambahkan delegasi ke OverlayVC
untuk mengirimkan acara scrollview ke penterjemah terjemahan kami, yang 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)
}
}
Dalam wadah kami, kami melacak terjemahan menggunakan enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Perhitungan posisi saat ini terlihat seperti:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Kami membutuhkan 3 metode untuk menangani terjemahan:
Yang pertama memberi tahu kami jika kami perlu memulai terjemahan.
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
}
}
Yang kedua melakukan terjemahan. Ini menggunakan translation(in:)
metode gerakan pan 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)
)
}
Yang ketiga menghidupkan akhir terjemahan ketika pengguna melepaskan jarinya. Kami menghitung posisi menggunakan kecepatan & posisi tampilan saat ini.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Implementasi delegasi overlay kami terlihat seperti:
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()
}
}
Masalah terakhir: mengirimkan sentuhan penampung hamparan
Terjemahan sekarang cukup efisien. Tetapi masih ada masalah terakhir: sentuhan tidak dikirim ke tampilan latar belakang kita. Mereka semua dicegat oleh tampilan penampung hamparan. Kita tidak bisa mengatur isUserInteractionEnabled
untuk false
karena juga akan menonaktifkan interaksi dalam pandangan meja kami. Solusinya adalah yang digunakan secara besar-besaran dalam aplikasi Maps, 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
}
}
Ini menghilangkan dirinya sendiri dari rantai responden.
Dalam OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Hasil
Inilah hasilnya:
Anda dapat menemukan kode di sini .
Silakan jika Anda melihat ada bug, beri tahu saya! Perhatikan bahwa implementasi Anda tentu saja dapat menggunakan gerakan pan kedua, khususnya jika Anda menambahkan header di hamparan Anda.
Perbarui 23/08/18
Kami dapat mengganti scrollViewDidEndDragging
dengan willEndScrollingWithVelocity
alih -
alih enabling
/ disabling
scroll ketika pengguna berakhir menyeret:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Kita dapat menggunakan animasi pegas dan memungkinkan interaksi pengguna saat animasi untuk membuat gerakan mengalir lebih baik:
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)
}