forked from qmathe/DropDownMenuKit
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathDropDownMenu.swift
More file actions
345 lines (291 loc) · 10.6 KB
/
DropDownMenu.swift
File metadata and controls
345 lines (291 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
/**
Copyright (C) 2015 Quentin Mathe
Date: May 2015
License: MIT
*/
import UIKit
@objc public protocol DropDownMenuDelegate {
func didTapInDropDownMenuBackground(_ menu: DropDownMenu)
}
public enum DropDownMenuRevealDirection {
case up
case down
}
open class DropDownMenu : UIView, UITableViewDataSource, UITableViewDelegate, UIGestureRecognizerDelegate {
open weak var delegate: DropDownMenuDelegate?
open weak var container: UIView? {
didSet {
removeFromSuperview()
container?.addSubview(self)
}
}
// The content view fills the entire container, so we can use it to fade
// the background view in and out.
//
// By default, it contains the menu view, but other subviews can be added to
// it and laid out by overriding -layoutSubviews.
public let contentView = UIView(frame: .zero)
public let menuView = UITableView(frame: .zero)
open var menuCells = [DropDownMenuCell]() {
didSet {
menuView.reloadData()
setNeedsLayout()
}
}
open var isAnimating = false
// The background view to be faded out with the background alpha, when the
// menu slides over it
open var backgroundView: UIView? {
didSet {
oldValue?.removeFromSuperview()
backgroundView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]
backgroundView?.alpha = oldValue?.alpha ?? 0
if let backgroundView = backgroundView {
insertSubview(backgroundView, belowSubview: contentView)
}
}
}
open var backgroundAlpha = CGFloat(1)
// MARK: - Initialization
override public init(frame: CGRect) {
super.init(frame: frame)
setUp()
}
required public init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
open override func awakeFromNib() {
setUp()
}
private func setUp() {
menuView.frame.size = frame.size
menuView.autoresizingMask = .flexibleWidth
if #available(iOS 11.0, *) {
// Prevent table cells to be shifted downwards abruptly before the menu slides up under the navigation bar
menuView.contentInsetAdjustmentBehavior = .never
menuView.insetsContentViewsToSafeArea = false
}
menuView.isScrollEnabled = true
menuView.bounces = false
menuView.showsVerticalScrollIndicator = true
menuView.showsHorizontalScrollIndicator = false
menuView.dataSource = self
menuView.delegate = self
contentView.frame.size = frame.size
contentView.autoresizingMask = [.flexibleWidth]
contentView.addSubview(menuView)
let gesture = UITapGestureRecognizer(target: self, action: #selector(DropDownMenu.tap(_:)))
gesture.delegate = self
addGestureRecognizer(gesture)
autoresizingMask = [.flexibleWidth, .flexibleHeight]
isHidden = true
addSubview(contentView)
}
// MARK: - Layout
// This hidden insets can be used to customize the position of the menu at
// the end of the hiding animation.
//
// If the container doesn't extend under the toolbar and navigation bar,
// this is useful to ensure the hiding animation continues until the menu is
// positioned outside of the screen, rather than stopping the animation when
// the menu is covered by the toolbar or navigation bar.
//
// Left and right insets are currently ignored.
open var hiddenContentInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) {
didSet {
setNeedsLayout()
}
}
// This visible insets can be used to customize the position of the menu
// at the end of the showing animation.
//
// If the container extends under the toolbar and navigation bar, this is
// useful to ensure the menu won't be covered by the toolbar or navigation
// bar once the showing animation is done.
//
// Left and right insets are currently ignored.
open var visibleContentInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) {
didSet {
// Menu height needs to be recomputed
setNeedsLayout()
}
}
open var direction = DropDownMenuRevealDirection.down {
didSet {
setNeedsLayout()
}
}
public enum Operation {
case show
case hide
}
private func updateContentViewPosition(on operation: Operation) {
guard let container = container else {
fatalError("DropDownMenu.container must have been set to layout subviews")
}
switch (operation, direction) {
case (.hide, .down):
contentView.frame.origin.y = hiddenContentInsets.top - contentView.frame.height
case (.hide, .up):
contentView.frame.origin.y = container.frame.height - hiddenContentInsets.bottom
case (.show, .down):
contentView.frame.origin.y = visibleContentInsets.top
case (.show, .up):
contentView.frame.origin.y = container.frame.height - contentView.frame.height - visibleContentInsets.bottom
}
}
open override func layoutSubviews() {
super.layoutSubviews()
let contentHeight = menuCells.reduce(0) { $0 + $1.rowHeight } + (menuView.tableFooterView?.frame.height ?? 0)
let maxContentHeight = frame.height - visibleContentInsets.bottom - visibleContentInsets.top
let scrollable = contentHeight > maxContentHeight
menuView.frame.size.height = scrollable ? maxContentHeight : contentHeight
contentView.frame.size.height = menuView.frame.height
updateContentViewPosition(on: isHidden ? .hide : .show)
// Reset scroll view content offset after rotation
if menuView.visibleCells.isEmpty {
return
}
menuView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .top, animated: false)
}
// MARK: - Animations
public struct Transition {
public struct Animation {
public typealias Block = () -> ()
public let before: Block
public let change: Block
public let after: Block
public init(before: @escaping Block = {}, change: @escaping Block, after: @escaping Block = {}) {
self.before = before
self.change = change
self.after = after
}
}
/// The duration taken by the animation when hiding/showing the menu and its background.
public var duration: TimeInterval
fileprivate let delay: TimeInterval = 0
public let options: AnimationOptions
public var show: [Animation]
public var hide: [Animation]
}
public lazy var transition = Transition(
duration: 0.4,
options: AnimationOptions(),
show: [showMenuAnimation, showBackgroundAnimation],
hide: [hideMenuAnimation, hideBackgroundAnimation]
)
/// The animation part in charge of showing the menu (doesn't include the background animation).
public private(set) lazy var showMenuAnimation = Transition.Animation(change: {
self.updateContentViewPosition(on: .show)
})
/// The animation part in charge of showing the background (doesn't include the menu animation).
public private(set) lazy var showBackgroundAnimation = Transition.Animation(change: {
self.backgroundView?.alpha = self.backgroundAlpha
})
/// The animation part in charge of hiding the menu (doesn't include the background animation).
public private(set) lazy var hideMenuAnimation = Transition.Animation(change: {
self.updateContentViewPosition(on: .hide)
})
/// The animation part in charge of hiding the background (doesn't include the menu animation).
///
/// Must set `self.isHidden = true` when the animation completes (this implies the background
/// hiding animation is expected to have a duration equal or greater to the menu hiding animation).
public private(set) lazy var hideBackgroundAnimation = Transition.Animation(change: {
self.backgroundView?.alpha = 0
})
// MARK: - Selection
/// Selects the cell briefly and sends the cell menu action.
///
/// If DropDownMenuCell.showsCheckmark is true, then the cell is marked with
/// a checkmark and all other cells are unchecked.
open func selectMenuCell(_ cell: DropDownMenuCell) {
guard let index = menuCells.firstIndex(of: cell) else {
fatalError("The menu cell to select must belong to the menu")
}
let indexPath = IndexPath(row: index, section: 0)
menuView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
tableView(menuView, didSelectRowAt: indexPath)
}
// MARK: - Actions
@IBAction open func tap(_ sender: AnyObject) {
delegate?.didTapInDropDownMenuBackground(self)
}
// If we declare a protocol method private, it is not called anymore.
open func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
precondition(gestureRecognizer.view == self)
guard let touchedView = touch.view else {
return true
}
return !touchedView.isDescendant(of: menuView)
}
@IBAction open func show() {
if !isHidden {
return
}
transition.show.map { $0.before }.joined()()
isHidden = false
isAnimating = true
UIView.animate(withDuration: transition.duration,
delay: transition.delay,
options: transition.options,
animations: transition.show.map { $0.change }.joined(),
completion: { _ in self.transition.show.map { $0.after }.joined()()
self.isAnimating = false })
}
@IBAction open func hide() {
if isHidden {
return
}
transition.hide.map { $0.before }.joined()()
isAnimating = true
UIView.animate(withDuration: transition.duration,
delay: transition.delay,
options: transition.options,
animations: transition.hide.map { $0.change }.joined(),
completion: { _ in
self.transition.hide.map { $0.after }.joined()()
self.isHidden = true
self.isAnimating = false
})
}
// MARK: - Table View
open func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return menuCells.count
}
open func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return menuCells[indexPath.row].rowHeight
}
open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
return menuCells[indexPath.row]
}
open func tableView(_ tableView: UITableView, shouldHighlightRowAt indexPath: IndexPath) -> Bool {
return menuCells[indexPath.row].menuAction != nil
}
open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let cell = menuCells[indexPath.row]
for cell in menuCells {
cell.accessoryType = .none
}
cell.accessoryType = cell.showsCheckmark ? .checkmark : .none
tableView.deselectRow(at: indexPath, animated: true)
guard let menuAction = cell.menuAction else {
return
}
#if APP_EXTENSION
guard let menuTarget = cell.menuTarget, menuTarget.responds(to: menuAction) else {
return
}
_ = menuTarget.perform(menuAction, with: cell)
#else
UIApplication.shared.sendAction(menuAction, to: cell.menuTarget, from: cell, for: nil)
#endif
}
}
private extension Array where Element == DropDownMenu.Transition.Animation.Block {
func joined() -> DropDownMenu.Transition.Animation.Block {
return { self.forEach { $0() } }
}
}