Skip to content

Commit 87753a2

Browse files
authored
Merge pull request #18522 from wordpress-mobile/feature/highlights-tooltip-container
Feature/highlights tooltip container
2 parents e01ddc4 + af1b3a2 commit 87753a2

File tree

3 files changed

+295
-1
lines changed

3 files changed

+295
-1
lines changed
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
import UIKit
2+
3+
final class Tooltip: UIView {
4+
private enum Constants {
5+
static let leadingIconUnicode = ""
6+
static let cornerRadius: CGFloat = 4
7+
static let arrowTipYLength: CGFloat = 11
8+
static let arrowTipYControlLength: CGFloat = 12
9+
static let maxWidth: CGFloat = UIScreen.main.bounds.width - Constants.Spacing.superHorizontalMargin
10+
11+
enum Spacing {
12+
static let contentStackViewInterItemSpacing: CGFloat = 4
13+
static let buttonsStackViewInterItemSpacing: CGFloat = 16
14+
static let contentStackViewTop: CGFloat = 12
15+
static let contentStackViewBottom: CGFloat = 4
16+
static let contentStackViewHorizontal: CGFloat = 16
17+
static let superHorizontalMargin: CGFloat = 16
18+
static let buttonStackViewHeight: CGFloat = 40
19+
}
20+
21+
enum Font {
22+
static let title = WPStyleGuide.fontForTextStyle(.body)
23+
static let message = WPStyleGuide.fontForTextStyle(.body)
24+
static let button = WPStyleGuide.fontForTextStyle(.subheadline)
25+
}
26+
}
27+
28+
enum ButtonAlignment {
29+
case left
30+
case right
31+
}
32+
33+
enum ArrowPosition {
34+
case top
35+
case bottom
36+
}
37+
38+
/// Determines whether a leading icon for the title, should be placed or not.
39+
var shouldPrefixLeadingIcon: Bool = true {
40+
didSet {
41+
guard let title = title else { return }
42+
43+
Self.updateTitleLabel(
44+
titleLabel,
45+
with: title,
46+
shouldPrefixLeadingIcon: shouldPrefixLeadingIcon
47+
)
48+
}
49+
}
50+
51+
/// String for primary label. To be used as the title.
52+
/// If `shouldPrefixLeadingIcon` is `true`, a leading icon will be prefixed.
53+
var title: String? {
54+
didSet {
55+
guard let title = title else {
56+
titleLabel.text = nil
57+
return
58+
}
59+
60+
Self.updateTitleLabel(
61+
titleLabel,
62+
with: title,
63+
shouldPrefixLeadingIcon: shouldPrefixLeadingIcon
64+
)
65+
accessibilityLabel = title
66+
}
67+
}
68+
69+
/// String for secondary label. To be used as description
70+
var message: String? {
71+
didSet {
72+
messageLabel.text = message
73+
accessibilityValue = message
74+
}
75+
}
76+
77+
/// Determines the alignment for the action buttons.
78+
var buttonAlignment: ButtonAlignment = .left {
79+
didSet {
80+
buttonsStackView.removeAllSubviews()
81+
switch buttonAlignment {
82+
case .left:
83+
buttonsStackView.addArrangedSubviews([primaryButton, secondaryButton, UIView()])
84+
case .right:
85+
buttonsStackView.addArrangedSubviews([UIView(), primaryButton, secondaryButton])
86+
}
87+
}
88+
}
89+
90+
private lazy var titleLabel: UILabel = {
91+
$0.font = Constants.Font.title
92+
$0.textColor = .invertedLabel
93+
return $0
94+
}(UILabel())
95+
96+
private lazy var messageLabel: UILabel = {
97+
$0.font = Constants.Font.message
98+
$0.textColor = .invertedSecondaryLabel
99+
$0.numberOfLines = 3
100+
return $0
101+
}(UILabel())
102+
103+
private(set) lazy var primaryButton: UIButton = {
104+
$0.titleLabel?.font = Constants.Font.button
105+
$0.setTitleColor(.primaryLight, for: .normal)
106+
return $0
107+
}(UIButton())
108+
109+
private(set) lazy var secondaryButton: UIButton = {
110+
$0.titleLabel?.font = Constants.Font.button
111+
$0.setTitleColor(.primaryLight, for: .normal)
112+
return $0
113+
}(UIButton())
114+
115+
private lazy var contentStackView: UIStackView = {
116+
$0.addArrangedSubviews([titleLabel, messageLabel, buttonsStackView])
117+
$0.spacing = Constants.Spacing.contentStackViewInterItemSpacing
118+
$0.axis = .vertical
119+
return $0
120+
}(UIStackView())
121+
122+
private lazy var buttonsStackView: UIStackView = {
123+
$0.addArrangedSubviews([primaryButton, secondaryButton, UIView()])
124+
$0.spacing = Constants.Spacing.buttonsStackViewInterItemSpacing
125+
return $0
126+
}(UIStackView())
127+
128+
private static func updateTitleLabel(
129+
_ titleLabel: UILabel,
130+
with text: String,
131+
shouldPrefixLeadingIcon: Bool) {
132+
133+
if shouldPrefixLeadingIcon {
134+
titleLabel.text = Constants.leadingIconUnicode + " " + text
135+
} else {
136+
titleLabel.text = text
137+
}
138+
}
139+
140+
private let containerView = UIView()
141+
private var containerTopConstraint: NSLayoutConstraint?
142+
private var containerBottomConstraint: NSLayoutConstraint?
143+
144+
init() {
145+
super.init(frame: .zero)
146+
commonInit()
147+
}
148+
149+
required init?(coder: NSCoder) {
150+
super.init(coder: coder)
151+
commonInit()
152+
}
153+
154+
/// Adds a tooltip Arrow Head at the given X Offset and either to the top or the bottom.
155+
/// - Parameters:
156+
/// - offsetX: The offset on which the arrow will be placed. The value must be above 0 and below maxX of the view.
157+
/// - arrowPosition: Arrow will be placed either on `.top`, pointed up, or `.bottom`, pointed down.
158+
func addArrowHead(toXPosition offsetX: CGFloat, arrowPosition: ArrowPosition) {
159+
let arrowTipY: CGFloat
160+
let arrowTipYControl: CGFloat
161+
let offsetY: CGFloat
162+
163+
switch arrowPosition {
164+
case .top:
165+
offsetY = 0
166+
arrowTipY = Constants.arrowTipYLength * -1
167+
arrowTipYControl = Constants.arrowTipYControlLength * -1
168+
containerTopConstraint?.constant = Constants.arrowTipYControlLength
169+
containerBottomConstraint?.constant = 0
170+
case .bottom:
171+
offsetY = Self.height(withTitle: titleLabel.text, message: message)
172+
arrowTipY = Constants.arrowTipYLength
173+
arrowTipYControl = Constants.arrowTipYControlLength
174+
containerTopConstraint?.constant = 0
175+
containerBottomConstraint?.constant = Constants.arrowTipYControlLength
176+
}
177+
178+
let arrowPath = UIBezierPath()
179+
arrowPath.move(to: CGPoint(x: 0, y: 0))
180+
// In order to have a full width of 20, first draw the left side of the triangle until 9.
181+
arrowPath.addLine(to: CGPoint(x: 9, y: arrowTipY))
182+
// Add curve until 11 (2 points of curve for a rounded arrow tip).
183+
arrowPath.addQuadCurve(
184+
to: CGPoint(x: 11, y: arrowTipY),
185+
controlPoint: CGPoint(x: 10, y: arrowTipYControl)
186+
)
187+
// Draw down to 20.
188+
arrowPath.addLine(to: CGPoint(x: 20, y: 0))
189+
arrowPath.close()
190+
191+
let shapeLayer = CAShapeLayer()
192+
shapeLayer.path = arrowPath.cgPath
193+
shapeLayer.strokeColor = UIColor.invertedSystem5.cgColor
194+
shapeLayer.fillColor = UIColor.invertedSystem5.cgColor
195+
shapeLayer.lineWidth = 1.0
196+
197+
shapeLayer.position = CGPoint(x: offsetX, y: offsetY)
198+
199+
containerView.layer.addSublayer(shapeLayer)
200+
}
201+
202+
private func commonInit() {
203+
backgroundColor = .clear
204+
205+
setUpContainerView()
206+
setUpConstraints()
207+
isAccessibilityElement = true
208+
}
209+
210+
private func setUpContainerView() {
211+
containerView.backgroundColor = .invertedSystem5
212+
containerView.layer.cornerRadius = Constants.cornerRadius
213+
containerView.translatesAutoresizingMaskIntoConstraints = false
214+
addSubview(containerView)
215+
216+
containerTopConstraint = containerView.topAnchor.constraint(equalTo: topAnchor)
217+
containerBottomConstraint = bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
218+
219+
NSLayoutConstraint.activate([
220+
containerTopConstraint!,
221+
containerView.leadingAnchor.constraint(equalTo: leadingAnchor),
222+
trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
223+
containerBottomConstraint!
224+
])
225+
}
226+
227+
private func setUpConstraints() {
228+
contentStackView.translatesAutoresizingMaskIntoConstraints = false
229+
buttonsStackView.translatesAutoresizingMaskIntoConstraints = false
230+
containerView.addSubview(contentStackView)
231+
232+
NSLayoutConstraint.activate([
233+
contentStackView.topAnchor.constraint(
234+
equalTo: containerView.topAnchor,
235+
constant: Constants.Spacing.contentStackViewTop
236+
),
237+
contentStackView.leadingAnchor.constraint(
238+
equalTo: containerView.leadingAnchor,
239+
constant: Constants.Spacing.contentStackViewHorizontal
240+
),
241+
containerView.trailingAnchor.constraint(
242+
equalTo: contentStackView.trailingAnchor,
243+
constant: Constants.Spacing.contentStackViewHorizontal
244+
),
245+
containerView.bottomAnchor.constraint(
246+
equalTo: contentStackView.bottomAnchor,
247+
constant: Constants.Spacing.contentStackViewBottom
248+
),
249+
containerView.widthAnchor.constraint(lessThanOrEqualToConstant: Constants.maxWidth),
250+
buttonsStackView.heightAnchor.constraint(equalToConstant: Constants.Spacing.buttonStackViewHeight)
251+
])
252+
}
253+
254+
private static func height(
255+
withTitle title: String?,
256+
message: String?) -> CGFloat {
257+
var totalHeight: CGFloat = 0
258+
259+
totalHeight += Constants.Spacing.contentStackViewTop
260+
261+
if let title = title {
262+
totalHeight += title.height(withMaxWidth: Constants.maxWidth, font: Constants.Font.title)
263+
}
264+
265+
totalHeight += Constants.Spacing.contentStackViewInterItemSpacing
266+
267+
if let message = message {
268+
totalHeight += message.height(withMaxWidth: Constants.maxWidth, font: Constants.Font.message)
269+
}
270+
271+
totalHeight += Constants.Spacing.buttonStackViewHeight
272+
totalHeight += Constants.Spacing.contentStackViewBottom
273+
274+
return totalHeight
275+
}
276+
}
277+
278+
private extension String {
279+
func height(withMaxWidth maxWidth: CGFloat, font: UIFont) -> CGFloat {
280+
let constraintRect = CGSize(width: maxWidth, height: .greatestFiniteMagnitude)
281+
let boundingBox = self.boundingRect(
282+
with: constraintRect,
283+
options: .usesLineFragmentOrigin,
284+
attributes: [.font: font],
285+
context: nil
286+
)
287+
288+
return ceil(boundingBox.height)
289+
}
290+
}

WordPress/Classes/ViewRelated/Feature Highlight/TooltipAnchor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ final class TooltipAnchor: UIControl {
3939
}
4040

4141
func dismissByFadingOut() {
42-
UIView.animate(withDuration: 0.3) {
42+
UIView.animate(withDuration: 0.2) {
4343
self.alpha = 0
4444
} completion: { _ in
4545
self.removeFromSuperview()

WordPress/WordPress.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@
127127
08D345531CD7F50900358E8C /* MenusSelectionView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D3454F1CD7F50900358E8C /* MenusSelectionView.m */; };
128128
08D345561CD7FBA900358E8C /* MenuHeaderViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D345551CD7FBA900358E8C /* MenuHeaderViewController.m */; };
129129
08D499671CDD20450004809A /* Menus.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 08D499661CDD20450004809A /* Menus.storyboard */; };
130+
08D553662821286300AA1E8D /* Tooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D553652821286300AA1E8D /* Tooltip.swift */; };
130131
08D978551CD2AF7D0054F19A /* Menu+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784C1CD2AF7D0054F19A /* Menu+ViewDesign.m */; };
131132
08D978561CD2AF7D0054F19A /* MenuItem+ViewDesign.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D9784E1CD2AF7D0054F19A /* MenuItem+ViewDesign.m */; };
132133
08D978571CD2AF7D0054F19A /* MenuItemCheckButtonView.m in Sources */ = {isa = PBXBuildFile; fileRef = 08D978501CD2AF7D0054F19A /* MenuItemCheckButtonView.m */; };
@@ -4946,6 +4947,7 @@
49464947
08D345551CD7FBA900358E8C /* MenuHeaderViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = MenuHeaderViewController.m; sourceTree = "<group>"; };
49474948
08D499661CDD20450004809A /* Menus.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Menus.storyboard; sourceTree = "<group>"; };
49484949
08D4C0E61C76F14E002E5BF6 /* WordPress 47.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "WordPress 47.xcdatamodel"; sourceTree = "<group>"; };
4950+
08D553652821286300AA1E8D /* Tooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tooltip.swift; sourceTree = "<group>"; };
49494951
08D9784B1CD2AF7D0054F19A /* Menu+ViewDesign.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "Menu+ViewDesign.h"; sourceTree = "<group>"; };
49504952
08D9784C1CD2AF7D0054F19A /* Menu+ViewDesign.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "Menu+ViewDesign.m"; sourceTree = "<group>"; };
49514953
08D9784D1CD2AF7D0054F19A /* MenuItem+ViewDesign.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "MenuItem+ViewDesign.h"; sourceTree = "<group>"; };
@@ -8255,6 +8257,7 @@
82558257
isa = PBXGroup;
82568258
children = (
82578259
081E4B4B281C019A0085E89C /* TooltipAnchor.swift */,
8260+
08D553652821286300AA1E8D /* Tooltip.swift */,
82588261
);
82598262
path = "Feature Highlight";
82608263
sourceTree = "<group>";
@@ -18769,6 +18772,7 @@
1876918772
FA3536F525B01A2C0005A3A0 /* JetpackRestoreCompleteViewController.swift in Sources */,
1877018773
7E7947AD210BAC7B005BB851 /* FormattableNoticonRange.swift in Sources */,
1877118774
46F583AB2624CE790010A723 /* BlockEditorSettings+CoreDataProperties.swift in Sources */,
18775+
08D553662821286300AA1E8D /* Tooltip.swift in Sources */,
1877218776
FAB985C12697550C00B172A3 /* NoResultsViewController+StatsModule.swift in Sources */,
1877318777
B55086211CC15CCB004EADB4 /* PromptViewController.swift in Sources */,
1877418778
40C403EC2215CD1300E8C894 /* SearchResultsStatsRecordValue+CoreDataProperties.swift in Sources */,

0 commit comments

Comments
 (0)