Skip to content

Commit 83a7a18

Browse files
committed
Added behavior like NSPopUpButton when Mac Catalyst target environment.
1 parent fbff869 commit 83a7a18

File tree

3 files changed

+125
-35
lines changed

3 files changed

+125
-35
lines changed

Example/PopUpButton/ViewController.swift

+6-1
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,15 @@ class ViewController: UIViewController {
2323
buttons = (0..<4).map({ i -> PopUpButton in
2424
let button = PopUpButton(items: items)
2525
button.backgroundColor = .black
26-
button.cover = .blur(.dark)
26+
#if !targetEnvironment(macCatalyst)
2727
if UIDevice.current.userInterfaceIdiom == .pad {
2828
button.anchor = .viewController(navigationController!)
2929
}
30+
button.cover = .blur(.dark)
31+
#else
32+
button.cover = .color(nil)
33+
button.selectionTouchInsideOnly = true
34+
#endif
3035
button.layer.cornerRadius = 12
3136
button.currentIndex = Double(i + 1) / 4.0 > 0.5 ? 5 : 15
3237
button.addTarget(self, action: #selector(popUpButtonTouchUpInside), for: .valueChanged)

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# PopUpButton
22

3-
A control for selecting an item from a list. In other words, single motion `NSPopUpButton` for iOS.
3+
A control for selecting an item from a list. In other words, single motion version of `NSPopUpButton` for iOS, and original version for Mac Catalyst.
44

55
```swift
66
public final class PopUpButton : UIControl {
@@ -16,6 +16,8 @@ public final class PopUpButton : UIControl {
1616
public var items: [Item] { get set }
1717

1818
public var currentIndex: Int { get set }
19+
20+
public var selectionTouchInsideOnly: Bool { get set }
1921

2022
public struct Item {
2123
public let title: String

Sources/PopUpButton/PopUpButton.swift

+116-33
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,46 @@ public final class PopUpButton: UIControl {
1313
private var coverView: UIView? {
1414
willSet { coverView?.removeFromSuperview() }
1515
}
16+
private var _selectedIndex: Int? {
17+
didSet {
18+
guard oldValue != _selectedIndex else { return }
19+
if window != nil {
20+
if let old = oldValue {
21+
let oldView = views[old]
22+
if !isPresented {
23+
oldView.removeFromSuperview()
24+
} else {
25+
oldView.backgroundColor = itemsColor ?? backgroundColor
26+
}
27+
}
28+
if let new = _selectedIndex {
29+
let newView = views[new]
30+
if !isPresented {
31+
addSubview(newView)
32+
}
33+
newView.backgroundColor = selectedItemColor ?? tintColor
34+
}
35+
}
36+
}
37+
}
38+
private var isPresented: Bool {
39+
#if targetEnvironment(macCatalyst)
40+
return isFirstResponder
41+
#else
42+
return isTracking
43+
#endif
44+
}
1645

1746
public override var canBecomeFirstResponder: Bool { true }
47+
#if targetEnvironment(macCatalyst)
48+
public override var frame: CGRect {
49+
didSet {
50+
if oldValue.origin != frame.origin, isPresented {
51+
layoutViews(from: convert(bounds, to: coverView), index: _selectedIndex ?? currentIndex)
52+
}
53+
}
54+
}
55+
#endif
1856

1957
public var itemsColor: UIColor?
2058
public var selectedItemColor: UIColor?
@@ -33,22 +71,16 @@ public final class PopUpButton: UIControl {
3371
}
3472
public var currentIndex: Int = 0 {
3573
didSet {
36-
guard oldValue != currentIndex else { return }
37-
if window != nil {
38-
let oldView = views[oldValue]
39-
if !isTracking {
40-
oldView.removeFromSuperview()
41-
} else {
42-
oldView.backgroundColor = itemsColor ?? backgroundColor
43-
}
44-
let newView = views[currentIndex]
45-
if !isTracking {
46-
addSubview(newView)
47-
}
48-
newView.backgroundColor = selectedItemColor ?? tintColor
74+
guard window != nil, views.count > 0 else { return }
75+
let newView = views[currentIndex]
76+
if !isPresented {
77+
views[oldValue].removeFromSuperview()
78+
addSubview(newView)
4979
}
80+
newView.backgroundColor = nil
5081
}
5182
}
83+
public var selectionTouchInsideOnly: Bool = false
5284

5385
public convenience init(items: [Item], frame: CGRect = .zero) {
5486
precondition(items.count > 0, "Items cannot be empty")
@@ -76,9 +108,9 @@ public final class PopUpButton: UIControl {
76108

77109
public override func layoutSubviews() {
78110
super.layoutSubviews()
79-
if isTracking, window != nil {
80-
let currentView = views[currentIndex]
81-
layoutViews(from: currentView.frame)
111+
if isPresented, window != nil, let index = _selectedIndex {
112+
let currentView = views[index]
113+
layoutViews(from: currentView.frame, index: index)
82114
} else if views.count > currentIndex {
83115
views[currentIndex].frame = bounds
84116
}
@@ -88,14 +120,22 @@ public final class PopUpButton: UIControl {
88120
guard let spaceView = anchor.view(for: self) else { return false }
89121
guard super.beginTracking(touch, with: event) && super.becomeFirstResponder() else { return false }
90122

123+
_selectedIndex = currentIndex
124+
91125
let (cover, content) = self.cover.build()
126+
#if targetEnvironment(macCatalyst)
127+
let tapCover = UITapGestureRecognizer(target: self, action: #selector(_tapInCover(_:)))
128+
let hoverCover = UIHoverGestureRecognizer(target: self, action: #selector(_hoverInCover(_:)))
129+
cover.addGestureRecognizer(tapCover)
130+
cover.addGestureRecognizer(hoverCover)
131+
#endif
92132
cover.frame = spaceView.bounds
93133
spaceView.addSubview(cover)
94134

95135
let current = views[currentIndex]
96136
current.backgroundColor = selectedItemColor ?? tintColor
97137
let anchorRect = cover.convert(current.frame, from: self)
98-
layoutViews(from: anchorRect)
138+
layoutViews(from: anchorRect, index: currentIndex)
99139
views.enumerated().forEach({ i, v in
100140
if i != currentIndex {
101141
v.backgroundColor = itemsColor ?? backgroundColor
@@ -111,38 +151,64 @@ public final class PopUpButton: UIControl {
111151
super.continueTracking(touch, with: event)
112152
guard let cover = coverView else { return false }
113153
let pointInCover = convert(touch.location(in: self), to: cover)
114-
guard let index = views.firstIndex(where: { $0.frame.minY < pointInCover.y && $0.frame.maxY > pointInCover.y }) else { return true }
115-
currentIndex = index
154+
_onTracking(in: cover, point: pointInCover)
155+
return true
156+
}
157+
158+
public override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
159+
super.endTracking(touch, with: event)
160+
#if !targetEnvironment(macCatalyst)
161+
resignFirstResponder()
162+
#endif
163+
}
164+
165+
public override func becomeFirstResponder() -> Bool { false }
166+
167+
@discardableResult
168+
public override func resignFirstResponder() -> Bool {
169+
guard super.resignFirstResponder() else { return false }
170+
_onResignFirstResponder()
171+
return true
172+
}
173+
174+
private func _onTracking(in cover: UIView, point pointInCover: CGPoint) {
175+
guard let index = views.firstIndex(where: { $0.frame.minY < pointInCover.y && $0.frame.maxY > pointInCover.y }) else { return }
176+
let selected = views[index]
177+
guard !selectionTouchInsideOnly || selected.frame.contains(pointInCover) else {
178+
_selectedIndex = nil
179+
return
180+
}
181+
_selectedIndex = index
116182
var nextIndex = index
117183
if pointInCover.y <= (frame.height + cover.safeAreaInsets.top) {
118184
nextIndex = max(0, index - 1)
119185
} else if pointInCover.y >= (cover.bounds.maxY - frame.height - cover.safeAreaInsets.bottom) {
120186
nextIndex = min(views.count - 1, index + 1)
121187
}
122-
if nextIndex != currentIndex {
123-
currentIndex = nextIndex
124-
layoutViews(from: views[index].frame)
188+
if nextIndex != _selectedIndex {
189+
_selectedIndex = nextIndex
190+
layoutViews(from: views[index].frame, index: nextIndex)
125191
}
126-
return true
127192
}
128193

129-
public override func endTracking(_ touch: UITouch?, with event: UIEvent?) {
130-
super.endTracking(touch, with: event)
131-
super.resignFirstResponder()
194+
private func _onResignFirstResponder() {
132195
coverView = nil
133-
let current = views[currentIndex]
134-
current.backgroundColor = nil
135-
addSubview(current)
196+
guard let newIndex = _selectedIndex, newIndex != currentIndex else {
197+
let current = views[currentIndex]
198+
current.backgroundColor = nil
199+
addSubview(current)
200+
return
201+
}
202+
_selectedIndex = nil
203+
currentIndex = newIndex
136204
sendActions(for: .valueChanged)
137205
}
138206

139-
public override func becomeFirstResponder() -> Bool { false }
140-
141-
private func layoutViews(from rect: CGRect) {
207+
private func layoutViews(from rect: CGRect, index: Int) {
142208
views.enumerated().forEach { i, view in
143209
view.frame = CGRect(
144210
x: rect.minX,
145-
y: rect.minY + (CGFloat(i - currentIndex) * rect.height),
211+
y: rect.minY + (CGFloat(i - index) * rect.height),
146212
width: rect.width, height: rect.height
147213
)
148214
}
@@ -171,6 +237,21 @@ public final class PopUpButton: UIControl {
171237
}
172238
}
173239
}
240+
241+
#if targetEnvironment(macCatalyst)
242+
@objc func _tapInCover(_ gesture: UITapGestureRecognizer) {
243+
defer { resignFirstResponder() }
244+
guard views.count > 0 else { return }
245+
let location = gesture.location(in: gesture.view)
246+
guard views[0].frame.minX < location.x, views[0].frame.minY < location.y else { return }
247+
guard views.last!.frame.maxX > location.x, views.last!.frame.maxY > location.y else { return }
248+
_selectedIndex = Int((location.y - views[0].frame.minY) / views[0].frame.height)
249+
}
250+
251+
@objc func _hoverInCover(_ gesture: UIHoverGestureRecognizer) {
252+
_onTracking(in: gesture.view!, point: gesture.location(in: gesture.view))
253+
}
254+
#endif
174255
}
175256
extension PopUpButton {
176257
public struct Item {
@@ -220,9 +301,11 @@ extension PopUpButton {
220301
case .color(let color):
221302
let view = UIView()
222303
view.backgroundColor = color
304+
view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
223305
return (view, view)
224306
case .blur(let style):
225307
let view = UIVisualEffectView(effect: UIBlurEffect(style: style))
308+
view.autoresizingMask = [.flexibleHeight, .flexibleWidth]
226309
return (view, view.contentView)
227310
}
228311
}

0 commit comments

Comments
 (0)