Skip to content

Commit 4fcd30a

Browse files
author
Semen Osipov
committed
Created bottom sheet controller
1 parent 7848581 commit 4fcd30a

12 files changed

Lines changed: 858 additions & 0 deletions

File tree

.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ARBottomSheetViewController.xcodeproj/project.pbxproj

Lines changed: 439 additions & 0 deletions
Large diffs are not rendered by default.

ARBottomSheetViewController.xcodeproj/project.xcworkspace/contents.xcworkspacedata

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>IDEDidComputeMac32BitWarning</key>
6+
<true/>
7+
</dict>
8+
</plist>

ARBottomSheetViewController.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# ``ARBottomSheetViewController``
2+
3+
iOS bottom sheet written on Swift
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// ARBottomSheetViewController.h
3+
// ARBottomSheetViewController
4+
//
5+
// Created by Семён C. Осипов on 14.02.2024.
6+
//
7+
8+
#import <Foundation/Foundation.h>
9+
10+
//! Project version number for ARBottomSheetViewController.
11+
FOUNDATION_EXPORT double ARBottomSheetViewControllerVersionNumber;
12+
13+
//! Project version string for ARBottomSheetViewController.
14+
FOUNDATION_EXPORT const unsigned char ARBottomSheetViewControllerVersionString[];
15+
16+
// In this header, you should import all the public headers of your framework using statements like #import <ARBottomSheetViewController/PublicHeader.h>
17+
18+
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
//
2+
// ARBottomSheetViewController.swift
3+
//
4+
//
5+
// Created by Семён C. Осипов on 14.02.2024.
6+
//
7+
8+
import UIKit
9+
import SnapKit
10+
11+
protocol ARBottomSheetPresentationViewControllerDelegate: AnyObject {
12+
var preferedControllerHeight: CGFloat {get}
13+
var interactionScrollView: UIScrollView? {get}
14+
}
15+
16+
class ARBottomSheetPresentationViewController: UIPresentationController {
17+
18+
private weak var customDelegate: ARBottomSheetPresentationViewControllerDelegate?
19+
20+
private var originalY: CGFloat = 0
21+
private var controllerHeight: CGFloat?
22+
23+
private var panGestureRecognizer: ARInitialTouchPanGestureRecognizer?
24+
25+
private lazy var dimmingView: UIView = {
26+
let view = UIView()
27+
let recognizer = UITapGestureRecognizer(target: self,
28+
action: #selector(handleTap(recognizer:)))
29+
recognizer.delegate = self
30+
view.addGestureRecognizer(recognizer)
31+
return view
32+
}()
33+
34+
init(presentedViewController: UIViewController,
35+
presenting presentingViewController: UIViewController?,
36+
height: CGFloat = UIScreen.main.bounds.height) {
37+
38+
self.controllerHeight = height
39+
super.init(presentedViewController: presentedViewController,
40+
presenting: presentingViewController)
41+
42+
}
43+
44+
init(presentedViewController: UIViewController,
45+
presenting presentingViewController: UIViewController?,
46+
delegate: ARBottomSheetPresentationViewControllerDelegate) {
47+
super.init(presentedViewController: presentedViewController, presenting: presentingViewController)
48+
self.customDelegate = delegate
49+
}
50+
51+
override func containerViewWillLayoutSubviews() {
52+
super.containerViewWillLayoutSubviews()
53+
presentedView?.frame = frameOfPresentedViewInContainerView
54+
}
55+
56+
override var frameOfPresentedViewInContainerView: CGRect {
57+
guard let container = containerView else { return super.frameOfPresentedViewInContainerView }
58+
let width = container.bounds.size.width
59+
let preferedHeight: CGFloat = customDelegate?.preferedControllerHeight ?? controllerHeight ?? maxHeight()
60+
let height = min(preferedHeight, maxHeight())
61+
62+
return CGRect(x: 0, y: container.bounds.size.height - height, width: width, height: height)
63+
}
64+
65+
private func maxHeight() -> CGFloat {
66+
let screenHeight = UIScreen.main.bounds.height
67+
var topPadding: CGFloat = 0
68+
if let window = UIApplication.shared.windows.first {
69+
topPadding = window.safeAreaInsets.top
70+
}
71+
return screenHeight - topPadding
72+
}
73+
74+
override func presentationTransitionWillBegin() {
75+
guard let containerView = containerView else { return }
76+
77+
containerView.insertSubview(dimmingView, at: 0)
78+
79+
let viewPan = ARInitialTouchPanGestureRecognizer(target: self, action: #selector(viewPanned(_:)))
80+
viewPan.delegate = self
81+
containerView.addGestureRecognizer(viewPan)
82+
panGestureRecognizer = viewPan
83+
84+
if let panGestureRecognizer = panGestureRecognizer {
85+
customDelegate?.interactionScrollView?.panGestureRecognizer.require(toFail: panGestureRecognizer)
86+
}
87+
88+
dimmingView.snp.makeConstraints {
89+
$0.top.bottom.leading.trailing.equalToSuperview()
90+
}
91+
92+
dimmingView.backgroundColor = .black.withAlphaComponent(0)
93+
UIView.animate(withDuration: 0.25) {
94+
self.dimmingView.backgroundColor = .black.withAlphaComponent(0.5)
95+
}
96+
}
97+
98+
override func dismissalTransitionWillBegin() {
99+
UIView.animate(withDuration: 0.25) {
100+
self.dimmingView.backgroundColor = .black.withAlphaComponent(0)
101+
}
102+
}
103+
104+
// MARK: Gestures
105+
@objc private func viewPanned(_ sender: UIPanGestureRecognizer) {
106+
let translateY = sender.translation(in: presentedView).y
107+
let velocityY = sender.velocity(in: presentedView).y
108+
109+
switch sender.state {
110+
case .began:
111+
originalY = presentedViewController.view.frame.origin.y
112+
case .changed:
113+
if translateY > 0 {
114+
presentedViewController.view.frame.origin.y = originalY + translateY
115+
} /*else {
116+
let diff = presentedViewController.view.frame.origin.y / presentingViewController.view.frame.size.height
117+
presentedView?.frame.origin.y = originalY + translateY * diff
118+
containerView?.layoutSubviews()
119+
}*/
120+
case .ended:
121+
let presentedViewHeight = presentedViewController.view.frame.height
122+
let newY = presentedViewController.view.frame.origin.y + velocityY * 0.2
123+
let isMoreHalfProgress = dimmingView.frame.height - presentedViewHeight * 0.5 < newY
124+
isMoreHalfProgress ? moveAndDismissPresentedView() : setBackToOriginalPosition()
125+
default:
126+
break
127+
}
128+
}
129+
130+
@objc func handleTap(recognizer: UITapGestureRecognizer) {
131+
presentingViewController.dismiss(animated: true)
132+
}
133+
134+
private func setBackToOriginalPosition() {
135+
presentedViewController.view.layoutIfNeeded()
136+
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseInOut, animations: {
137+
self.presentedViewController.view.frame.origin.y = self.originalY
138+
self.presentedViewController.view.layoutIfNeeded()
139+
}, completion: nil)
140+
}
141+
142+
private func moveAndDismissPresentedView() {
143+
presentedViewController.view.layoutIfNeeded()
144+
dismissalTransitionWillBegin()
145+
UIView.animate(withDuration: 0.25, delay: 0.0, options: .curveEaseIn, animations: {
146+
self.presentedViewController.view.frame.origin.y = self.dimmingView.frame.height
147+
self.presentedViewController.view.layoutIfNeeded()
148+
}, completion: { _ in
149+
self.presentingViewController.dismiss(animated: true, completion: nil)
150+
})
151+
}
152+
}
153+
154+
extension ARBottomSheetPresentationViewController: UIGestureRecognizerDelegate {
155+
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
156+
guard let panGestureRecognizer = gestureRecognizer as? ARInitialTouchPanGestureRecognizer else { return true }
157+
let velocity = panGestureRecognizer.velocity(in: presentedView)
158+
guard abs(velocity.y) > abs(velocity.x) else {return false}
159+
160+
if let childScrollView = customDelegate?.interactionScrollView,
161+
let view = presentedView,
162+
let point = panGestureRecognizer.initialTouchLocation {
163+
let pointInChildScrollView = view.convert(point, to: childScrollView).y - childScrollView.contentOffset.y
164+
guard pointInChildScrollView > 0, pointInChildScrollView < childScrollView.bounds.height else {
165+
return true
166+
}
167+
168+
let closeDirection = panGestureRecognizer.translation(in: presentedView).y > 0
169+
let contentOffset = childScrollView.contentOffset.y
170+
171+
if closeDirection {
172+
return contentOffset <= -childScrollView.contentInset.top
173+
} else {
174+
return false
175+
/* let offset = contentOffset + childScrollView.bounds.height - childScrollView.contentInset.bottom
176+
let contentSize = childScrollView.contentSize.height.rounded(.toNearestOrAwayFromZero)
177+
return offset >= contentSize */
178+
}
179+
}
180+
return true
181+
}
182+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//
2+
// ARBottomSheetViewController.swift
3+
//
4+
//
5+
// Created by Семён C. Осипов on 14.02.2024.
6+
//
7+
8+
import UIKit
9+
import SnapKit
10+
11+
public protocol ARBottomSheetViewControllerDelegate: AnyObject {
12+
var contentHeight: CGFloat {get}
13+
var childScrollView: UIScrollView? {get}
14+
}
15+
16+
public class ARBottomSheetViewController: UIViewController {
17+
18+
public weak var sheetDelegate: ARBottomSheetViewControllerDelegate?
19+
20+
// MARK: Views
21+
private let stickView: UIView = {
22+
let view = UIView()
23+
if #available(iOS 13.0, *) {
24+
view.backgroundColor = .label
25+
} else {
26+
view.backgroundColor = .black
27+
}
28+
view.snp.makeConstraints {
29+
$0.width.equalTo(65)
30+
$0.height.equalTo(5)
31+
}
32+
view.layer.cornerRadius = 2.5
33+
return view
34+
}()
35+
36+
private let contentView: UIView = {
37+
let view = UIView()
38+
view.backgroundColor = .clear
39+
return view
40+
}()
41+
42+
/**
43+
Add your content on this view
44+
*/
45+
public var contentBackgroundView: UIView {
46+
contentView
47+
}
48+
49+
/**
50+
Init View controller
51+
*/
52+
public init() {
53+
super.init(nibName: nil, bundle: nil)
54+
commonInit()
55+
}
56+
57+
required init?(coder: NSCoder) {
58+
super.init(coder: coder)
59+
commonInit()
60+
}
61+
62+
private func commonInit() {
63+
modalPresentationStyle = .custom
64+
transitioningDelegate = self
65+
setupViews()
66+
67+
view.clipsToBounds = true
68+
view.layer.cornerRadius = 30
69+
view.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
70+
71+
if #available(iOS 13.0, *) {
72+
view.backgroundColor = .systemBackground
73+
} else {
74+
view.backgroundColor = .white
75+
}
76+
}
77+
78+
private func setupViews() {
79+
view.addSubview(stickView)
80+
view.addSubview(contentView)
81+
82+
stickView.snp.makeConstraints {
83+
$0.centerX.equalToSuperview()
84+
$0.top.equalToSuperview().inset(20)
85+
}
86+
87+
contentView.snp.makeConstraints {
88+
$0.leading.trailing.bottom.equalToSuperview()
89+
$0.top.equalTo(stickView.snp.bottom).offset(20)
90+
}
91+
}
92+
}
93+
94+
// MARK: - UIViewControllerTransitioningDelegate
95+
extension ARBottomSheetViewController: UIViewControllerTransitioningDelegate {
96+
public func presentationController(forPresented presented: UIViewController,
97+
presenting: UIViewController?,
98+
source: UIViewController) -> UIPresentationController? {
99+
return ARBottomSheetPresentationViewController(presentedViewController: presented,
100+
presenting: presenting,
101+
delegate: self)
102+
}
103+
}
104+
105+
// MARK: - LRVBottomSheetPresentationViewControllerDelegate
106+
extension ARBottomSheetViewController: ARBottomSheetPresentationViewControllerDelegate {
107+
var interactionScrollView: UIScrollView? {
108+
return sheetDelegate?.childScrollView
109+
}
110+
111+
var preferedControllerHeight: CGFloat {
112+
let stickSize: CGFloat = 5 + 20 + 20
113+
var bottomPadding: CGFloat = 0
114+
if let window = UIApplication.shared.windows.first {
115+
bottomPadding = window.safeAreaInsets.bottom
116+
}
117+
return stickSize + (sheetDelegate?.contentHeight ?? 0) + bottomPadding
118+
}
119+
}
120+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
//
2+
// ARInitialTouchPanGestureRecognizer.swift
3+
//
4+
//
5+
// Created by Семён C. Осипов on 14.02.2024.
6+
//
7+
8+
import UIKit.UIGestureRecognizerSubclass
9+
10+
class ARInitialTouchPanGestureRecognizer: UIPanGestureRecognizer {
11+
var initialTouchLocation: CGPoint?
12+
13+
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent) {
14+
super.touchesBegan(touches, with: event)
15+
initialTouchLocation = touches.first?.location(in: view)
16+
}
17+
}
18+

0 commit comments

Comments
 (0)