气泡功能本身由 FTPopOverMenu 实现,相关代码可以在 GitHub 上查找。然而,原始代码中的气泡箭头过于尖锐,弹出效果显得突兀。因此,在这里我们对主要文件 FTPopOverMenu 进行了改进,实现了弹出时黑色蒙版的渐变效果以及箭头部分的圆角处理。

具体修改方法的代码如下:

  1. 通过设置 alpha 值,实现弹出时蒙版的渐变效果。
  2. 利用三角函数计算来确定路径,从而实现箭头部分的圆角处理。

经过这样的优化,我们提升了气泡功能的细节体验。

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

fileprivate func doneActionWithSelectedIndex(selectedIndex: NSInteger) {
isOnScreen = false
UIView.animate(withDuration: FT.DefaultAnimationDuration) {
self.popOverMenuView.alpha = 0
self.backgroundView.alpha = 0
self.popOverMenuView.transform = CGAffineTransform(scaleX: 0.1, y: 0.1)
} completion: { [unowned self] _ in
self.backgroundView.removeFromSuperview()
if selectedIndex < 0 {
self.cancel?()
self.cancel = nil
} else {
self.done?(selectedIndex)
self.done = nil
}
}
}

func getBackgroundPath(arrowPoint: CGPoint) -> UIBezierPath {
let viewWidth = bounds.size.width
let viewHeight = bounds.size.height

let radius: CGFloat = configuration.cornerRadius

let path: UIBezierPath = UIBezierPath()
path.lineJoinStyle = .round
path.lineCapStyle = .round

if arrowDirection == .up {
path.move(to: CGPoint(x: arrowPoint.x - FT.DefaultMenuArrowWidth, y: FT.DefaultMenuArrowHeight))

let aDealta = FT.DefaultArrowCornerRadius * FT.DefaultMenuArrowHeight / sqrt(pow(FT.DefaultMenuArrowHeight, 2) + pow(FT.DefaultMenuArrowWidth, 2))
let ax = arrowPoint.x - aDealta
let ay = FT.DefaultMenuArrowHeight * aDealta / FT.DefaultMenuArrowWidth
path.addLine(to: CGPoint(x: ax, y: ay))

let degree = .pi - 2 * atan(FT.DefaultMenuArrowWidth / FT.DefaultMenuArrowHeight)
let degreeDelta = (.pi - degree) / 2
let startAngle = -.pi + degreeDelta
let endAngle = startAngle + degree
path.addArc(withCenter: CGPoint(x: arrowPoint.x, y: ay + pow(aDealta, 2) / ay),
radius: FT.DefaultArrowCornerRadius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: true)

path.addLine(to: CGPoint(x: arrowPoint.x + FT.DefaultMenuArrowWidth, y: FT.DefaultMenuArrowHeight))
path.addLine(to: CGPoint(x: viewWidth - radius, y: FT.DefaultMenuArrowHeight))
path.addArc(withCenter: CGPoint(x: viewWidth - radius, y: FT.DefaultMenuArrowHeight + radius),
radius: radius,
startAngle: .pi / 2 * 3,
endAngle: 0,
clockwise: true)
path.addLine(to: CGPoint(x: viewWidth, y: viewHeight - radius))
path.addArc(withCenter: CGPoint(x: viewWidth - radius, y: viewHeight - radius),
radius: radius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true)
path.addLine(to: CGPoint(x: radius, y: viewHeight))
path.addArc(withCenter: CGPoint(x: radius, y: viewHeight - radius),
radius: radius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true)
path.addLine(to: CGPoint(x: 0, y: FT.DefaultMenuArrowHeight + radius))
path.addArc(withCenter: CGPoint(x: radius, y: FT.DefaultMenuArrowHeight + radius),
radius: radius,
startAngle: .pi,
endAngle: .pi / 2 * 3,
clockwise: true)
path.close()
} else {
path.move(to: CGPoint(x: arrowPoint.x - FT.DefaultMenuArrowWidth, y: viewHeight - FT.DefaultMenuArrowHeight))

let aDealta = FT.DefaultArrowCornerRadius * FT.DefaultMenuArrowHeight / sqrt(pow(FT.DefaultMenuArrowHeight, 2) + pow(FT.DefaultMenuArrowWidth, 2))
let bDealta = FT.DefaultMenuArrowHeight * aDealta / FT.DefaultMenuArrowWidth
let ax = arrowPoint.x - aDealta
let ay = viewHeight - bDealta
path.addLine(to: CGPoint(x: ax, y: ay))

let degree = .pi - 2 * atan(FT.DefaultMenuArrowWidth / FT.DefaultMenuArrowHeight)
let degreeDelta = (.pi - degree) / 2
let startAngle = .pi - degreeDelta
let endAngle = startAngle - degree
path.addArc(withCenter: CGPoint(x: arrowPoint.x, y: ay - pow(aDealta, 2) / bDealta),
radius: FT.DefaultArrowCornerRadius,
startAngle: startAngle,
endAngle: endAngle,
clockwise: false)

path.addLine(to: CGPoint(x: arrowPoint.x + FT.DefaultMenuArrowWidth, y: viewHeight - FT.DefaultMenuArrowHeight))
path.addLine(to: CGPoint(x: viewWidth - radius, y: viewHeight - FT.DefaultMenuArrowHeight))
path.addArc(withCenter: CGPoint(x: viewWidth - radius, y: viewHeight - FT.DefaultMenuArrowHeight - radius),
radius: radius,
startAngle: .pi / 2,
endAngle: 0,
clockwise: false)
path.addLine(to: CGPoint(x: viewWidth, y: radius))
path.addArc(withCenter: CGPoint(x: viewWidth - radius, y: radius),
radius: radius,
startAngle: 0,
endAngle: .pi / 2 * 3,
clockwise: false)
path.addLine(to: CGPoint(x: radius, y: 0))
path.addArc(withCenter: CGPoint(x: radius, y: radius),
radius: radius,
startAngle: .pi / 2 * 3,
endAngle: .pi,
clockwise: false)
path.addLine(to: CGPoint(x: 0, y: viewHeight - FT.DefaultMenuArrowHeight - radius))
path.addArc(withCenter: CGPoint(x: radius, y: viewHeight - FT.DefaultMenuArrowHeight - radius),
radius: radius,
startAngle: .pi,
endAngle: .pi / 2,
clockwise: false)
path.close()
}
return path
}
}


此外,可以对 FTPopOverMenuModel 进行优化和改进,以封装更多的菜单项相关信息。这将使使用者只需关注创建合适的模型。通常,我会在 extraInfo 中添加封装成 block 的选择事件。同时,通过添加 NSMutableAttributedString,我们可以更好地实现特殊菜单项的样式,例如下图所示。红点是通过 NSTextAttachment 实现的。

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

public class FTPopOverMenuModel: NSObject {
public var title: String = ""
public var attributeTitle: NSMutableAttributedString? = nil // icon_audition_draft_discRed 红点
public var image: Imageable?
public var selected: Bool = false
public var extraInfo: Any?

public convenience init(title: String, attributeTitle: NSMutableAttributedString? = nil, image: Imageable?, selected: Bool, extraInfo: Any? = nil) {
self.init()
self.title = title
self.attributeTitle = attributeTitle
self.image = image
self.selected = selected
self.extraInfo = extraInfo
}
}

func attributeStrExample() {
...
// 创建一个带红点图片的属性字符串
let attributedString = NSMutableAttributedString(string: item)
let textAttachment = NSTextAttachment()
textAttachment.image = R.image.icon_audition_draft_discRed()
textAttachment.bounds = CGRect(x: 0, y: 10, width: 4, height: 4)
let attachmentString = NSAttributedString(attachment: textAttachment)
attributedString.append(attachmentString)

result.append(FTPopOverMenuModel(title: "", attributeTitle: attributedString, image: nil, selected: false, extraInfo: ["action": block]))

...
}

// FTPopOverMenuCell的setupCellWith方法
func setupCellWith(menuName: FTMenuObject, menuImage: Imageable?, configuration: FTConfiguration) {
...
if menuName is String {
nameLabel.text = menuName as? String
iconImage = menuImage?.getImage()
} else if menuName is FTPopOverMenuModel {
if let attributeTitle = (menuName as! FTPopOverMenuModel).attributeTitle {
attributeTitle.addAttribute(.foregroundColor, value: configuration.selectedTextColor, range: NSRange(location: 0, length: attributeTitle.length))
attributeTitle.addAttribute(.font, value: configuration.selectedTextFont, range: NSRange(location: 0, length: attributeTitle.length))
nameLabel.attributedText = attributeTitle
} else {
nameLabel.text = (menuName as! FTPopOverMenuModel).title
if (menuName as! FTPopOverMenuModel).selected == true {
nameLabel.textColor = configuration.selectedTextColor
nameLabel.font = configuration.selectedTextFont
}
}
backgroundColor = configuration.selectedCellBackgroundColor
iconImage = (menuName as! FTPopOverMenuModel).image?.getImage()
}
...
}

Github地址