Tôi đã phát hành một thư viện dựa trên câu trả lời của tôi dưới đây.
Nó bắt chước lớp phủ ứng dụng Phím tắt. Xem bài viết này để biết chi tiết.
Thành phần chính của thư viện là OverlayContainerViewController
. Nó xác định một khu vực nơi một bộ điều khiển xem có thể được kéo lên xuống, ẩn hoặc tiết lộ nội dung bên dưới nó.
let contentController = MapsViewController()
let overlayController = SearchViewController()
let containerController = OverlayContainerViewController()
containerController.delegate = self
containerController.viewControllers = [
contentController,
overlayController
]
window?.rootViewController = containerController
Thực hiện OverlayContainerViewControllerDelegate
để xác định số lượng notch mong muốn:
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
}
}
Câu trả lời trước
Tôi nghĩ có một điểm quan trọng không được xử lý trong các giải pháp được đề xuất: sự chuyển đổi giữa cuộn và bản dịch.
Trong Bản đồ, như bạn có thể nhận thấy, khi bảngView đến contentOffset.y == 0
, bảng dưới cùng sẽ trượt lên hoặc đi xuống.
Vấn đề là khó khăn vì chúng ta không thể đơn giản bật / tắt cuộn khi cử chỉ pan của chúng ta bắt đầu dịch. Nó sẽ dừng cuộn cho đến khi một liên lạc mới bắt đầu. Đây là trường hợp trong hầu hết các giải pháp được đề xuất ở đây.
Đây là nỗ lực của tôi để thực hiện chuyển động này.
Điểm bắt đầu: Ứng dụng Bản đồ
Để bắt đầu cuộc điều tra của chúng tôi, chúng ta hãy hình dung hệ thống phân cấp quan điểm của Maps (bắt đầu Maps trên một mô phỏng và chọn Debug
> Attach to process by PID or Name
> Maps
trong Xcode 9).
Nó không cho biết chuyển động hoạt động như thế nào, nhưng nó giúp tôi hiểu logic của nó. Bạn có thể chơi với lldb và trình gỡ lỗi phân cấp xem.
Ngăn xếp điều khiển xem của chúng tôi
Hãy tạo một phiên bản cơ bản của kiến trúc Maps ViewControll.
Chúng tôi bắt đầu với một BackgroundViewController
(chế độ xem bản đồ của chúng tôi):
class BackgroundViewController: UIViewController {
override func loadView() {
view = MKMapView()
}
}
Chúng tôi đặt bảngView trong một chuyên dụng UIViewController
:
class OverlayViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
lazy var tableView = UITableView()
override func loadView() {
view = tableView
tableView.dataSource = self
tableView.delegate = self
}
[...]
}
Bây giờ, chúng ta cần một VC để nhúng lớp phủ và quản lý bản dịch của nó. Để đơn giản hóa vấn đề, chúng tôi xem xét rằng nó có thể dịch lớp phủ từ điểm tĩnh này OverlayPosition.maximum
sang điểm tĩnh khác OverlayPosition.minimum
.
Cho đến nay, nó chỉ có một phương thức công khai để làm thay đổi vị trí và nó có chế độ xem minh bạch:
enum OverlayPosition {
case maximum, minimum
}
class OverlayContainerViewController: UIViewController {
let overlayViewController: OverlayViewController
var translatedViewHeightContraint = ...
override func loadView() {
view = UIView()
}
func moveOverlay(to position: OverlayPosition) {
[...]
}
}
Cuối cùng, chúng ta cần một ViewContoder để nhúng tất cả:
class StackViewController: UIViewController {
private var viewControllers: [UIViewController]
override func viewDidLoad() {
super.viewDidLoad()
viewControllers.forEach { gz_addChild($0, in: view) }
}
}
Trong AppDelegate, chuỗi khởi động của chúng tôi trông như sau:
let overlay = OverlayViewController()
let containerViewController = OverlayContainerViewController(overlayViewController: overlay)
let backgroundViewController = BackgroundViewController()
window?.rootViewController = StackViewController(viewControllers: [backgroundViewController, containerViewController])
Khó khăn đằng sau bản dịch lớp phủ
Bây giờ, làm thế nào để dịch lớp phủ của chúng tôi?
Hầu hết các giải pháp được đề xuất đều sử dụng bộ nhận dạng cử chỉ pan chuyên dụng, nhưng chúng tôi thực sự đã có một: cử chỉ pan của chế độ xem bảng. Hơn nữa, chúng tôi cần giữ cho cuộn và bản dịch được đồng bộ hóa và UIScrollViewDelegate
có tất cả các sự kiện chúng tôi cần!
Việc triển khai ngây thơ sẽ sử dụng Gesture pan thứ hai và cố gắng đặt lại contentOffset
chế độ xem bảng khi bản dịch xảy ra:
func panGestureAction(_ recognizer: UIPanGestureRecognizer) {
if isTranslating {
tableView.contentOffset = .zero
}
}
Nhưng nó không hoạt động. TableView cập nhật contentOffset
khi kích hoạt hành động nhận dạng cử chỉ pan của chính nó hoặc khi gọi lại displayLink của nó được gọi. Không có cơ hội mà trình nhận dạng của chúng tôi kích hoạt ngay sau khi những người đó ghi đè thành công contentOffset
. Cơ hội duy nhất của chúng tôi là tham gia vào giai đoạn bố trí (bằng cách ghi đè layoutSubviews
các lệnh gọi xem cuộn ở mỗi khung của chế độ xem cuộn) hoặc để đáp ứng didScroll
phương thức của đại biểu được gọi mỗi lần contentOffset
sửa đổi. Hãy thử cái này.
Việc thực hiện dịch thuật
Chúng tôi thêm một đại biểu để OverlayVC
gửi các sự kiện của scrollview đến bộ xử lý dịch của chúng tôi, đó là 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)
}
}
Trong thùng chứa của chúng tôi, chúng tôi theo dõi bản dịch bằng enum:
enum OverlayInFlightPosition {
case minimum
case maximum
case progressing
}
Tính toán vị trí hiện tại trông như sau:
private var overlayInFlightPosition: OverlayInFlightPosition {
let height = translatedViewHeightContraint.constant
if height == maximumHeight {
return .maximum
} else if height == minimumHeight {
return .minimum
} else {
return .progressing
}
}
Chúng tôi cần 3 phương pháp để xử lý bản dịch:
Điều đầu tiên cho chúng ta biết nếu chúng ta cần bắt đầu dịch.
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
}
}
Người thứ hai thực hiện việc dịch thuật. Nó sử dụng translation(in:)
phương thức cử chỉ pan của 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)
)
}
Cái thứ ba hoạt hình kết thúc bản dịch khi người dùng thả ngón tay ra. Chúng tôi tính toán vị trí bằng cách sử dụng vận tốc và vị trí hiện tại của chế độ xem.
private func animateTranslationEnd() {
let position: OverlayPosition = // ... calculation based on the current overlay position & velocity
moveOverlay(to: position)
}
Việc triển khai đại biểu của lớp phủ của chúng tôi chỉ đơn giản là trông như sau:
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()
}
}
Vấn đề cuối cùng: gửi các liên lạc của lớp phủ
Bản dịch bây giờ khá hiệu quả. Nhưng vẫn còn một vấn đề cuối cùng: các cú chạm không được chuyển đến chế độ xem nền của chúng tôi. Tất cả đều bị chặn bởi chế độ xem của lớp phủ. Chúng tôi không thể đặt isUserInteractionEnabled
thành false
vì nó cũng sẽ vô hiệu hóa tương tác trong chế độ xem bảng của chúng tôi. Giải pháp là giải pháp được sử dụng ồ ạt trong ứng dụng Bản đồ , 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
}
}
Nó loại bỏ chính nó khỏi chuỗi phản hồi.
Trong OverlayContainerViewController
:
override func loadView() {
view = PassThroughView()
}
Kết quả
Đây là kết quả:
Bạn có thể tìm thấy mã ở đây .
Xin vui lòng nếu bạn thấy bất kỳ lỗi, cho tôi biết! Lưu ý rằng việc triển khai của bạn tất nhiên có thể sử dụng cử chỉ pan thứ hai, đặc biệt nếu bạn thêm tiêu đề trong lớp phủ của mình.
Cập nhật ngày 23/08/18
Chúng tôi có thể thay thế scrollViewDidEndDragging
bằng
willEndScrollingWithVelocity
thay vì enabling
/ disabling
cuộn khi người dùng kết thúc kéo:
func scrollView(_ scrollView: UIScrollView,
willEndScrollingWithVelocity velocity: CGPoint,
targetContentOffset: UnsafeMutablePointer<CGPoint>) {
switch overlayInFlightPosition {
case .maximum:
break
case .minimum, .progressing:
targetContentOffset.pointee = .zero
}
animateTranslationEnd(following: scrollView)
}
Chúng ta có thể sử dụng hoạt hình mùa xuân và cho phép người dùng tương tác trong khi hoạt hình để làm cho chuyển động tốt hơn:
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)
}