Skip to content

Commit 9e0beea

Browse files
authored
Add an unread dot to the AvatarGroup (#1866)
* Add isUnread var * Update a11y label for unread * Fix padding bug * Add unread dot * Update demo
1 parent de4316f commit 9e0beea

File tree

3 files changed

+85
-19
lines changed

3 files changed

+85
-19
lines changed

ios/FluentUI.Demo/FluentUI.Demo/Demos/AvatarGroupDemoController.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,7 @@ class AvatarGroupDemoController: DemoTableViewController {
588588

589589
avatarGroup.state.maxDisplayedAvatars = maxDisplayedAvatars
590590
avatarGroup.state.overflowCount = overflowCount
591+
avatarGroup.state.isUnread = row.avatarSize == .size20
591592
avatarGroupsForCurrentSection.updateValue(avatarGroup, forKey: row)
592593
allDemoAvatarGroupsCombined.append(avatarGroup)
593594
}

ios/FluentUI/AvatarGroup/AvatarGroup.swift

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import SwiftUI
1616
/// items than just the remainder of the avatars that could not be displayed due to the maxDisplayedAvatars property.
1717
var overflowCount: Int { get set }
1818

19+
/// Show a top-trailing aligned unread dot when set to true.
20+
var isUnread: Bool { get set }
21+
1922
/// Style of the AvatarGroup.
2023
var style: MSFAvatarGroupStyle { get set }
2124

@@ -190,13 +193,11 @@ public struct AvatarGroup: View, TokenizedControlView {
190193
style: FillStyle(eoFill: true))
191194
})
192195
}
193-
.padding(.trailing, isStackStyle ? stackPadding : interspace)
196+
.padding(.trailing, (isLastDisplayed && !hasOverflow) ? 0 : isStackStyle ? stackPadding : interspace)
194197
}
195198

196199
@ViewBuilder
197-
var avatarGroupContent: some View {
198-
let animation = Animation.linear(duration: animationDuration)
199-
200+
var avatarGroup: some View {
200201
HStack(spacing: 0) {
201202
ForEach(enumeratedAvatars.prefix(avatarsToDisplay), id: \.1) { index, avatar in
202203
avatarView(at: index, for: avatar)
@@ -210,19 +211,52 @@ public struct AvatarGroup: View, TokenizedControlView {
210211
.transition(AnyTransition.opacity)
211212
}
212213
}
213-
.animation(animation, value: state.avatars)
214-
.animation(animation, value: [state.maxDisplayedAvatars, state.overflowCount])
215-
.animation(animation, value: state.style)
216-
.animation(animation, value: state.size)
217-
.frame(maxWidth: .infinity,
218-
minHeight: groupHeight,
219-
maxHeight: .infinity,
220-
alignment: .leading)
221-
.accessibilityElement(children: .combine)
222-
.accessibilityLabel(groupLabel)
223214
}
224215

225-
return avatarGroupContent
216+
@ViewBuilder
217+
var unreadGroup: some View {
218+
if state.isUnread {
219+
let strokeWidth = AvatarGroupTokenSet.unreadDotStrokeWidth
220+
let dotSize = AvatarGroupTokenSet.unreadDotSize
221+
avatarGroup
222+
.overlay(alignment: .topTrailing) {
223+
Circle()
224+
.foregroundColor(Color(tokenSet[.unreadDotColor].uiColor))
225+
.frame(width: dotSize, height: dotSize)
226+
// Add half the strokeWidth as padding to get the stroke drawn around the outside of the
227+
// dot instead of having the stroke centered on the edge of the dot, but it needs to be
228+
// inset slightly to not have a gap.
229+
.padding(strokeWidth / 2 - strokeInset)
230+
.overlay {
231+
Circle()
232+
.stroke(Color(tokenSet[.backgroundColor].uiColor),
233+
lineWidth: strokeWidth)
234+
}
235+
.offset(x: AvatarGroupTokenSet.unreadDotHorizontalOffset,
236+
y: AvatarGroupTokenSet.unreadDotVerticalOffset)
237+
}
238+
} else {
239+
avatarGroup
240+
}
241+
}
242+
243+
@ViewBuilder
244+
var animatedGroup: some View {
245+
let animation = Animation.linear(duration: animationDuration)
246+
unreadGroup
247+
.animation(animation, value: state.avatars)
248+
.animation(animation, value: [state.maxDisplayedAvatars, state.overflowCount])
249+
.animation(animation, value: state.style)
250+
.animation(animation, value: state.size)
251+
.frame(maxWidth: .infinity,
252+
minHeight: groupHeight,
253+
maxHeight: .infinity,
254+
alignment: .leading)
255+
.accessibilityElement(children: .combine)
256+
.accessibilityLabel(groupLabel)
257+
}
258+
259+
return animatedGroup
226260
}
227261

228262
var avatarsToDisplay: Int {
@@ -253,6 +287,11 @@ public struct AvatarGroup: View, TokenizedControlView {
253287
str += String(format: "Accessibility.AvatarGroup.AvatarList".localized, displayedAvatarAccessibilityLabels[i])
254288
}
255289
str += String(format: "Accessibility.AvatarGroup.AvatarListLast".localized, displayedAvatarAccessibilityLabels.last ?? "")
290+
291+
if state.isUnread {
292+
str = String(format: "Accessibility.TabBarItemView.UnreadFormat".localized, str)
293+
}
294+
256295
return str
257296
}
258297

@@ -261,6 +300,7 @@ public struct AvatarGroup: View, TokenizedControlView {
261300
@ObservedObject var state: MSFAvatarGroupStateImpl
262301

263302
private let animationDuration: CGFloat = 0.1
303+
private let strokeInset: CGFloat = 0.1
264304

265305
private func createOverflow(count: Int) -> Avatar {
266306
let state = MSFAvatarStateImpl(style: .overflow, size: state.size)
@@ -301,6 +341,7 @@ class MSFAvatarGroupStateImpl: ControlState, MSFAvatarGroupState {
301341
@Published var avatars: [MSFAvatarGroupAvatarStateImpl] = []
302342
@Published var maxDisplayedAvatars: Int = Int.max
303343
@Published var overflowCount: Int = 0
344+
@Published var isUnread: Bool = false
304345

305346
@Published var style: MSFAvatarGroupStyle
306347
@Published var size: MSFAvatarSize

ios/FluentUI/AvatarGroup/AvatarGroupTokenSet.swift

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,26 @@ import SwiftUI
99
/// Design token set for the `AvatarGroup` control
1010
public class AvatarGroupTokenSet: ControlTokenSet<AvatarGroupTokenSet.Tokens> {
1111
public enum Tokens: TokenSetKey {
12+
/// Defines the color around the unread dot.
13+
case backgroundColor
14+
1215
/// CGFloat that defines the space between the `Avatar` controls hosted by the `AvatarGroup`.
1316
case interspace
17+
18+
/// Defines the color of the unread dot.
19+
case unreadDotColor
1420
}
1521

1622
init(style: @escaping () -> MSFAvatarGroupStyle,
1723
size: @escaping () -> MSFAvatarSize) {
1824
self.style = style
1925
self.size = size
20-
super.init { [style, size] token, _ in
21-
return .float {
22-
switch token {
23-
case .interspace:
26+
super.init { [style, size] token, theme in
27+
switch token {
28+
case .backgroundColor:
29+
return .uiColor { theme.color(.background1) }
30+
case .interspace:
31+
return .float {
2432
switch style() {
2533
case .stack:
2634
switch size() {
@@ -43,6 +51,8 @@ public class AvatarGroupTokenSet: ControlTokenSet<AvatarGroupTokenSet.Tokens> {
4351
}
4452
}
4553
}
54+
case .unreadDotColor:
55+
return .uiColor { theme.color(.brandForeground1) }
4656
}
4757
}
4858
}
@@ -53,3 +63,17 @@ public class AvatarGroupTokenSet: ControlTokenSet<AvatarGroupTokenSet.Tokens> {
5363
/// Defines the size of the `Avatar` controls in the `AvatarGroup`.
5464
var size: () -> MSFAvatarSize
5565
}
66+
67+
extension AvatarGroupTokenSet {
68+
/// Size of the background behind the unread dot.
69+
static let unreadDotStrokeWidth: CGFloat = GlobalTokens.stroke(.width20)
70+
71+
/// Size of the unread dot.
72+
static let unreadDotSize: CGFloat = 8.0
73+
74+
/// Vertical offset of the unread dot.
75+
static let unreadDotVerticalOffset: CGFloat = -3.0
76+
77+
/// Horizontal offset of the unread dot.
78+
static let unreadDotHorizontalOffset: CGFloat = 7.0
79+
}

0 commit comments

Comments
 (0)