Skip to content

Commit

Permalink
Implement the Unread Dot for TabBar and SideTabBar (#1349)
Browse files Browse the repository at this point in the history
* Implementing the unread dot on TabBarView

* Better naming for variables and events

* Updating SideTabBar demo to show the unread dot

* Pr feedback - reorganizing, renaming, better documentation

* Updating variable names and locations to follow guidance

* Added accessibility string for unread dot

* Switching to using a badge label for the unread dot

* Cleaning up commented out code

* Simplifying logic, using a single view for both the badge and the unreadDot

* PR cleanup
  • Loading branch information
edjamesmsft committed Nov 17, 2022
1 parent b61434f commit b72f51e
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ class SideTabBarDemoController: DemoController {
TabBarItem(title: "Open", image: UIImage(named: "Open_28")!, selectedImage: UIImage(named: "Open_Selected_28")!)
]

// Set the Open item to be unread
sideTabBar.topItems[2].isUnreadDotVisible = true

var premiumImage = UIImage(named: "ic_fluent_premium_24_regular")!
if let window = view.window {
let primaryColor = Colors.primary(for: window)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ class TabBarViewDemoController: DemoController {

private func setupTabBarView() {
// remove the old tab bar View
var isOpenFileUnread = true
if let oldTabBarView = tabBarView {
isOpenFileUnread = oldTabBarView.items[2].isUnreadDotVisible
if let constraints = tabBarViewConstraints {
NSLayoutConstraint.deactivate(constraints)
}
Expand All @@ -87,6 +89,9 @@ class TabBarViewDemoController: DemoController {
]
}

// If the open file item has been clicked, maintain that state through to the new item
updatedTabBarView.items[2].isUnreadDotVisible = isOpenFileUnread

updatedTabBarView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(updatedTabBarView)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@
/* Format string for tab bar item accessbility labels. Format: "<Title>, <BadgeValue> items". Example: "Home, 5 items" */
"Accessibility.TabBarItemView.LabelFormat" = "%@, %@ items";

/* Accessibility hint for TabBarItem in TabBarItemView. Indicates whether the item is unread or not. Format: "<Title>, unread". Example: "Files, unread" */
"Accessibility.TabBarItemView.UnreadFormat" = "%@, unread";

/* Format string for badge label button accessibility label. Format: "<Item Accessibility>, <Badge Label Accessibility>". Example: "Notifications, 5 new notifications" */
"Accessibility.BadgeLabelButton.LabelFormat" = "%@, %@";

Expand Down
14 changes: 14 additions & 0 deletions ios/FluentUI/Tab Bar/TabBarItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ open class TabBarItem: NSObject {
}
}

/// This value will determine whether or not to show the mark that represents the "unread" state.
/// If the badgeValue is set, the unreadDot will not be visible.
/// The default value of this property is false.
@objc public var isUnreadDotVisible: Bool = false {
didSet {
if oldValue != isUnreadDotVisible {
NotificationCenter.default.post(name: TabBarItem.isUnreadValueDidChangeNotification, object: self)
}
}
}

/// Convenience method to set the badge value to a number.
/// If the number is zero, the badge value will be hidden.
@objc public func setBadgeNumber(_ number: UInt) {
Expand Down Expand Up @@ -90,6 +101,9 @@ open class TabBarItem: NSObject {
/// Notification sent when the tab bar item's badge value changes.
static let badgeValueDidChangeNotification = NSNotification.Name(rawValue: "TabBarItemBadgeValueDidChangeNotification")

/// Notification sent when item's `isUnread` value changes.
static let isUnreadValueDidChangeNotification = NSNotification.Name(rawValue: "TabBarItemisUnreadValueDidChangeNotification")

let image: UIImage
let selectedImage: UIImage?
let landscapeImage: UIImage?
Expand Down
146 changes: 105 additions & 41 deletions ios/FluentUI/Tab Bar/TabBarItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class TabBarItemView: UIControl {
updateImage()
updateColors()
if isSelected {
if item.isUnreadDotVisible {
item.isUnreadDotVisible = false
updateBadgeView()
}
accessibilityTraits.insert(.selected)
} else {
accessibilityTraits.remove(.selected)
Expand Down Expand Up @@ -99,16 +103,21 @@ class TabBarItemView: UIControl {
scalesLargeContentImage = true

NSLayoutConstraint.activate([
container.centerXAnchor.constraint(equalTo: centerXAnchor),
container.centerYAnchor.constraint(equalTo: centerYAnchor),
container.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor)
])
container.centerXAnchor.constraint(equalTo: centerXAnchor),
container.centerYAnchor.constraint(equalTo: centerYAnchor),
container.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor)
])

NotificationCenter.default.addObserver(self,
selector: #selector(badgeValueDidChange),
name: TabBarItem.badgeValueDidChangeNotification,
object: item)

NotificationCenter.default.addObserver(self,
selector: #selector(isUnreadValueDidChange),
name: TabBarItem.isUnreadValueDidChangeNotification,
object: item)

badgeValue = item.badgeValue
updateLayout()
}
Expand Down Expand Up @@ -163,6 +172,10 @@ class TabBarItemView: UIControl {
static let badgeBorderWidth: CGFloat = 2
static let badgeHorizontalPadding: CGFloat = 10
static let badgeCorderRadii: CGFloat = 10
static let unreadDotPortraitOffsetX: CGFloat = 6.0
static let unreadDotOffsetX: CGFloat = 4.0
static let unreadDotOffsetY: CGFloat = 20.0
static let unreadDotSize: CGFloat = 8.0
}

private var badgeValue: String? {
Expand All @@ -174,6 +187,15 @@ class TabBarItemView: UIControl {
}
}

@objc private func isUnreadValueDidChange() {
isUnreadDotVisible = item.isUnreadDotVisible
updateBadgeView()
updateAccessibilityLabel()
setNeedsLayout()
}

private var isUnreadDotVisible: Bool = false

private let container: UIStackView = {
let container = UIStackView(frame: .zero)
container.alignment = .center
Expand Down Expand Up @@ -279,57 +301,89 @@ class TabBarItemView: UIControl {
}

private func updateBadgeView() {
badgeView.text = badgeValue
badgeView.isHidden = badgeValue == nil
isUnreadDotVisible = item.isUnreadDotVisible && badgeValue == nil

// If nothing to display, remove mask and return
if badgeValue == nil && !isUnreadDotVisible {
badgeView.isHidden = true
imageView.layer.mask = nil
return
}

if badgeValue != nil {
let maskLayer = CAShapeLayer()
maskLayer.fillRule = .evenOdd
// Otherwise, show either the badgeValue or an unreadDot
badgeView.isHidden = false
let maskLayer = CAShapeLayer()
maskLayer.fillRule = .evenOdd

let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: imageView.frame.size.width, height: imageView.frame.size.height))
let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: imageView.frame.size.width, height: imageView.frame.size.height))

if isUnreadDotVisible {
// Badge with empty string and round corners is a dot
badgeView.text = ""
let badgeVerticalOffset = !titleLabel.isHidden && isInPortraitMode ? Constants.unreadDotPortraitOffsetX : Constants.unreadDotOffsetX

createCircularBadgeFrame(labelView: badgeView,
path: path,
horizontalOffset: Constants.unreadDotOffsetY,
verticalOffset: badgeVerticalOffset,
frameWidth: Constants.unreadDotSize,
frameHeight: Constants.unreadDotSize)
} else {
badgeView.text = badgeValue
let badgeVerticalOffset = !titleLabel.isHidden && isInPortraitMode ? Constants.badgePortraitTitleVerticalOffset : Constants.badgeVerticalOffset

if badgeView.text?.count ?? 1 > 1 {
let badgeWidth = min(max(badgeView.intrinsicContentSize.width + Constants.badgeHorizontalPadding, Constants.badgeMinWidth), maxBadgeWidth)
createRoundedRectBadgeFrame(labelView: badgeView, path: path, verticalOffset: badgeVerticalOffset)
} else {
createCircularBadgeFrame(labelView: badgeView,
path: path,
horizontalOffset: Constants.singleDigitBadgeHorizontalOffset,
verticalOffset: badgeVerticalOffset,
frameWidth: Constants.badgeMinWidth,
frameHeight: Constants.badgeHeight)
}
}

badgeView.frame = CGRect(x: badgeFrameOriginX(offset: Constants.multiDigitBadgeHorizontalOffset, frameWidth: badgeWidth),
y: imageView.frame.origin.y + badgeVerticalOffset,
width: badgeWidth,
height: Constants.badgeHeight)
maskLayer.path = path.cgPath
imageView.layer.mask = maskLayer
}

let layer = CAShapeLayer()
layer.path = UIBezierPath(roundedRect: badgeView.bounds,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: Constants.badgeCorderRadii, height: Constants.badgeCorderRadii)).cgPath
private func createRoundedRectBadgeFrame(labelView: UILabel, path: UIBezierPath, verticalOffset: CGFloat) {
let width = min(max(labelView.intrinsicContentSize.width + Constants.badgeHorizontalPadding, Constants.badgeMinWidth), maxBadgeWidth)

path.append(UIBezierPath(roundedRect: badgeBorderRect(badgeViewFrame: badgeView.frame),
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: Constants.badgeCorderRadii, height: Constants.badgeCorderRadii)))
labelView.frame = CGRect(x: frameOriginX(offset: Constants.multiDigitBadgeHorizontalOffset, frameWidth: width),
y: imageView.frame.origin.y + verticalOffset,
width: width,
height: Constants.badgeHeight)

badgeView.layer.mask = layer
badgeView.layer.cornerRadius = 0
} else {
let badgeWidth = max(badgeView.intrinsicContentSize.width, Constants.badgeMinWidth)
let layer = CAShapeLayer()
layer.path = UIBezierPath(roundedRect: labelView.bounds,
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: Constants.badgeCorderRadii, height: Constants.badgeCorderRadii)).cgPath

badgeView.frame = CGRect(x: badgeFrameOriginX(offset: Constants.singleDigitBadgeHorizontalOffset, frameWidth: badgeWidth),
y: imageView.frame.origin.y + badgeVerticalOffset,
width: badgeWidth,
height: Constants.badgeHeight)
path.append(UIBezierPath(roundedRect: badgeBorderRect(badgeViewFrame: labelView.frame),
byRoundingCorners: .allCorners,
cornerRadii: CGSize(width: Constants.badgeCorderRadii, height: Constants.badgeCorderRadii)))

path.append(UIBezierPath(ovalIn: badgeBorderRect(badgeViewFrame: badgeView.frame)))
labelView.layer.mask = layer
labelView.layer.cornerRadius = 0
}

badgeView.layer.mask = nil
badgeView.layer.cornerRadius = badgeWidth / 2
}
private func createCircularBadgeFrame(labelView: UILabel, path: UIBezierPath, horizontalOffset: CGFloat, verticalOffset: CGFloat, frameWidth: CGFloat, frameHeight: CGFloat) {
let width = max(labelView.intrinsicContentSize.width, frameWidth)

maskLayer.path = path.cgPath
imageView.layer.mask = maskLayer
} else {
imageView.layer.mask = nil
}
labelView.frame = CGRect(x: frameOriginX(offset: horizontalOffset, frameWidth: width),
y: imageView.frame.origin.y + verticalOffset,
width: width,
height: frameHeight)

path.append(UIBezierPath(ovalIn: badgeBorderRect(badgeViewFrame: labelView.frame)))

labelView.layer.mask = nil
labelView.layer.cornerRadius = width / 2
}

private func badgeFrameOriginX(offset: CGFloat, frameWidth: CGFloat) -> CGFloat {
private func frameOriginX(offset: CGFloat, frameWidth: CGFloat) -> CGFloat {
var xOrigin: CGFloat = 0
if effectiveUserInterfaceLayoutDirection == .leftToRight {
xOrigin = imageView.frame.origin.x + offset
Expand All @@ -351,6 +405,12 @@ class TabBarItemView: UIControl {
badgeValue = item.badgeValue
}

// The priority logic for accessibility label is:
// If the badge is visible:
// 1. Use the badge format string supplied by the caller if available
// 2. If not, use the default localized badge label format
// If the unread dot is visible, use the localized "unread" label
// If neither, then use the item's title, as supplied by the caller
private func updateAccessibilityLabel() {
if let badgeValue = badgeValue {
if let accessibilityLabelBadgeFormatString = item.accessibilityLabelBadgeFormatString {
Expand All @@ -359,7 +419,11 @@ class TabBarItemView: UIControl {
accessibilityLabel = String(format: "Accessibility.TabBarItemView.LabelFormat".localized, item.title, badgeValue)
}
} else {
accessibilityLabel = item.title
if isUnreadDotVisible {
accessibilityLabel = String(format: "Accessibility.TabBarItemView.UnreadFormat".localized, item.title)
} else {
accessibilityLabel = item.title
}
}
}
}
Expand Down

0 comments on commit b72f51e

Please sign in to comment.