diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift index 8a79d2fae9..5623e9b886 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/NavigationControllerDemoController.swift @@ -473,6 +473,7 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe var showSearchProgressSpinner: Bool = true var showRainbowRingForAvatar: Bool = false var showBadgeOnBarButtonItem: Bool = false + var showRedDotOnBarButtonItem: Bool = false var allowsCellSelection: Bool = false { didSet { @@ -624,6 +625,20 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe } if indexPath.row == 3 { + guard let cell = tableView.dequeueReusableCell(withIdentifier: BooleanCell.identifier, for: indexPath) as? BooleanCell else { + return UITableViewCell() + } + cell.setup(title: "Show Red Dot on right bar button items", + isOn: showRedDotOnBarButtonItem, + isSwitchEnabled: navigationItem.titleStyle == .largeLeading) + cell.titleNumberOfLines = 0 + cell.onValueChanged = { [weak self, weak cell] in + self?.shouldShowRedDot(isOn: cell?.isOn ?? false) + } + return cell + } + + if indexPath.row == 4 { guard let cell = tableView.dequeueReusableCell(withIdentifier: ActionsCell.identifier, for: indexPath) as? ActionsCell else { return UITableViewCell() } @@ -748,6 +763,16 @@ class RootViewController: UIViewController, UITableViewDataSource, UITableViewDe showBadgeOnBarButtonItem = isOn } + @objc private func shouldShowRedDot(isOn: Bool) { + guard let items = navigationItem.rightBarButtonItems, !items.isEmpty else { + return + } + for item in items { + item.showRedDot(isOn) + } + showRedDotOnBarButtonItem = isOn + } + @objc private func showTooltipButtonPressed() { let navigationBar = msfNavigationController?.msfNavigationBar guard let view = navigationBar?.barButtonItemView(with: BarButtonItemTag.threeDay.rawValue) else { diff --git a/ios/FluentUI.xcodeproj/project.pbxproj b/ios/FluentUI.xcodeproj/project.pbxproj index ccd3b922fa..2f6f490d83 100644 --- a/ios/FluentUI.xcodeproj/project.pbxproj +++ b/ios/FluentUI.xcodeproj/project.pbxproj @@ -212,6 +212,7 @@ A542A9D7226FC01100204A52 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A559BB81212B6FA40055E107 /* Localizable.strings */; }; A542A9D8226FC01700204A52 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = A5DF1EAD2213B26900CC741A /* Localizable.stringsdict */; }; A5CEC16020D980B30016922A /* FluentUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEC15F20D980B30016922A /* FluentUITests.swift */; }; + B70204342B7DD41200E5B549 /* UIBarButtonItem+RedDot.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70204332B7DD41200E5B549 /* UIBarButtonItem+RedDot.swift */; }; C708B05F260A8778007190FA /* SegmentPillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C708B055260A86FA007190FA /* SegmentPillButton.swift */; }; C708B064260A87F7007190FA /* SegmentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C708B04B260A8696007190FA /* SegmentItem.swift */; }; C77A04B825F03DD1001B3EB6 /* String+Date.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77A04B625F03DD1001B3EB6 /* String+Date.swift */; }; @@ -417,6 +418,7 @@ B4E782C62179509A00A7DFCE /* CenteredLabelCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CenteredLabelCell.swift; sourceTree = ""; }; B4EF53C2215AF1AB00573E8F /* Persona.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Persona.swift; sourceTree = ""; }; B4EF66502294A664007FEAB0 /* TableViewHeaderFooterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewHeaderFooterView.swift; sourceTree = ""; }; + B70204332B7DD41200E5B549 /* UIBarButtonItem+RedDot.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIBarButtonItem+RedDot.swift"; sourceTree = ""; }; C0938E43235E8ED500256251 /* AnimationSynchronizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnimationSynchronizer.swift; sourceTree = ""; }; C0A0D76B233AEF6C00F432FD /* ShimmerLinesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShimmerLinesView.swift; sourceTree = ""; }; C0EAAEAC2347E1DF00C7244E /* ShimmerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShimmerView.swift; sourceTree = ""; }; @@ -1215,6 +1217,7 @@ 667E54012A12B6F800728F93 /* TwoLineTitleView+Navigation.swift */, FD41C8BD22DD47120086F899 /* UINavigationItem+Navigation.swift */, FD9DA7B4232C33A80013E41B /* UIViewController+Navigation.swift */, + B70204332B7DD41200E5B549 /* UIBarButtonItem+RedDot.swift */, 6ED4C11A2695A6E800C30BD6 /* UIBarButtonItem+BadgeValue.swift */, 6FBFD62429CBB5B9002F3C81 /* SearchBar */, FD41C87222DD13230086F899 /* Helpers */, @@ -1684,6 +1687,7 @@ ECA9218627A3301C00B66117 /* MSFAvatarGroup.swift in Sources */, 5314E0EC25F012C40099271A /* NavigationAnimator.swift in Sources */, 5314E17225F0191C0099271A /* Separator.swift in Sources */, + B70204342B7DD41200E5B549 /* UIBarButtonItem+RedDot.swift in Sources */, 5314E14225F016860099271A /* CardPresenterNavigationController.swift in Sources */, 5314E11725F015EA0099271A /* PersonaCell.swift in Sources */, 5314E23025F022C80099271A /* UIScrollView+Extensions.swift in Sources */, diff --git a/ios/FluentUI/Navigation/BadgeLabelButton.swift b/ios/FluentUI/Navigation/BadgeLabelButton.swift index 2fccd8599b..3b40a532f4 100644 --- a/ios/FluentUI/Navigation/BadgeLabelButton.swift +++ b/ios/FluentUI/Navigation/BadgeLabelButton.swift @@ -10,7 +10,7 @@ class BadgeLabelButton: UIButton { var item: UIBarButtonItem? { didSet { setupButton() - prepareButtonForBadgeLabel() + prepareButtonForBadge() } } @@ -25,6 +25,10 @@ class BadgeLabelButton: UIButton { configuration = UIButton.Configuration.plain() + NotificationCenter.default.addObserver(self, + selector: #selector(redDotVisibilitDidChanged), + name: UIBarButtonItem.redDotValueDidChangeNotification, + object: item) NotificationCenter.default.addObserver(self, selector: #selector(badgeValueDidChange), name: UIBarButtonItem.badgeValueDidChangeNotification, @@ -42,7 +46,7 @@ class BadgeLabelButton: UIButton { override func layoutSubviews() { super.layoutSubviews() - prepareButtonForBadgeLabel() + prepareButtonForBadge() } private struct Constants { @@ -54,6 +58,10 @@ class BadgeLabelButton: UIButton { static let badgeHorizontalPadding: CGFloat = 10 static let badgeCornerRadii: CGFloat = 10 + static let redDotWidth: CGFloat = 10 + static let redDotHeight: CGFloat = 10 + static let redDotCornerRadius: CGFloat = 5 + // These are consistent with UIKit's default navigation bar buttons static let maximumContentSizeCategory: UIContentSizeCategory = .extraExtraLarge static let minimumContentSizeCategory: UIContentSizeCategory = .large @@ -61,6 +69,12 @@ class BadgeLabelButton: UIButton { private let badgeLabel = BadgeLabel() + private lazy var redDotView: UIView = { + let view = UIView() + view.backgroundColor = .red + return view + }() + private var badgeWidth: CGFloat { return min(max(badgeLabel.intrinsicContentSize.width + Constants.badgeHorizontalPadding, Constants.badgeMinWidth), @@ -71,31 +85,44 @@ class BadgeLabelButton: UIButton { return (frame.size.height - intrinsicContentSize.height) / 2 - Constants.badgeHeight / 2 - Constants.badgeVerticalOffset } - private var badgeFrameOriginX: CGFloat { + private var redDotVerticalPosition: CGFloat { + return (frame.size.height - intrinsicContentSize.height) / 2 - Constants.redDotHeight / 2 - Constants.badgeVerticalOffset + } + + private func badgeFrameOriginX(_ viewWidth: CGFloat) -> CGFloat { let xOrigin: CGFloat = { return isLeftToRightUserInterfaceLayoutDirection ? frame.size.width - (configuration?.contentInsets.leading ?? 0) : configuration?.contentInsets.trailing ?? 0 }() - return (xOrigin - badgeWidth / 2) + return (xOrigin - viewWidth / 2) } private var badgeLabelFrame: CGRect { let targetView = isItemTitlePresent ? titleLabel : imageView - return CGRect(x: badgeFrameOriginX - (targetView?.frame.origin.x ?? 0), + return CGRect(x: badgeFrameOriginX(badgeWidth) - (targetView?.frame.origin.x ?? 0), y: badgeVerticalPosition - (targetView?.frame.origin.y ?? 0), width: badgeWidth, height: Constants.badgeHeight) } - private var badgeBoundsOriginX: CGFloat { + private var redDotFrame: CGRect { + let targetView = isItemTitlePresent ? titleLabel : imageView + + return CGRect(x: badgeFrameOriginX(Constants.redDotWidth) - (targetView?.frame.origin.x ?? 0), + y: redDotVerticalPosition - (targetView?.frame.origin.y ?? 0), + width: Constants.redDotWidth, + height: Constants.redDotHeight) + } + + private func badgeBoundsOriginX(_ viewWidth: CGFloat) -> CGFloat { let xOrigin: CGFloat = 0 if isLeftToRightUserInterfaceLayoutDirection { return xOrigin } else { - return xOrigin - badgeWidth / 2 + return xOrigin - viewWidth / 2 } } @@ -149,59 +176,86 @@ class BadgeLabelButton: UIButton { isPointerInteractionEnabled = true } - private func prepareButtonForBadgeLabel() { + private func prepareButtonForBadge() { if isItemTitlePresent, let titleLabel = titleLabel { titleLabel.addSubview(badgeLabel) + titleLabel.addSubview(redDotView) titleLabel.isHidden = false } else if let imageView = imageView { imageView.addSubview(badgeLabel) + imageView.addSubview(redDotView) imageView.isHidden = false imageView.clipsToBounds = false } - updateBadgeLabel() + updateBadge() } - private func updateBadgeLabel() { + private func updateBadge() { badgeLabel.text = item?.badgeValue let isNilBadgeValue = item?.badgeValue == nil badgeLabel.isHidden = isNilBadgeValue + redDotView.isHidden = (item?.shouldShowRedDot != true || !isNilBadgeValue) if isNilBadgeValue { - layer.mask = nil + if item?.shouldShowRedDot == true { + showRedDot() + } else { + layer.mask = nil + } } else { badgeLabel.frame = badgeLabelFrame + addBadgeOrRedDot(false) + } + } - let badgeLabelLayer = CAShapeLayer() - badgeLabelLayer.path = UIBezierPath(roundedRect: badgeLabel.bounds, - byRoundingCorners: .allCorners, - cornerRadii: CGSize(width: Constants.badgeCornerRadii, height: Constants.badgeCornerRadii)).cgPath - badgeLabel.layer.mask = badgeLabelLayer + private func showRedDot() { + redDotView.frame = redDotFrame + addBadgeOrRedDot(true) + } + + private func addBadgeOrRedDot(_ isRedDot: Bool) { + let viewBounds = isRedDot ? redDotView.bounds : badgeLabel.bounds + let viewCornerRadius = isRedDot ? Constants.redDotCornerRadius : Constants.badgeCornerRadii + + let badgeLabelLayer = CAShapeLayer() + badgeLabelLayer.path = UIBezierPath(roundedRect: viewBounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize( + width: viewCornerRadius, + height: viewCornerRadius)).cgPath - let computedBadgeWidth = badgeWidth - let badgeBounds = CGRect(x: badgeFrameOriginX, - y: badgeVerticalPosition, - width: computedBadgeWidth, - height: Constants.badgeHeight) - let badgeCutoutPath = UIBezierPath(rect: CGRect(x: badgeBoundsOriginX, - y: badgeBounds.origin.y, - width: frame.size.width + computedBadgeWidth / 2, - height: frame.size.height)) - // Adding the path for the cutout on the button's titleLabel or imageView where the badge label will be placed on top of. - badgeCutoutPath.append(UIBezierPath(roundedRect: badgeCutoutRect(for: badgeBounds), - byRoundingCorners: .allCorners, - cornerRadii: CGSize(width: Constants.badgeCornerRadii, - height: Constants.badgeCornerRadii))) - // Adding the path that will display the badge label with rounded corners on top of the cutout. - badgeCutoutPath.append(UIBezierPath(roundedRect: badgeBounds, - byRoundingCorners: .allCorners, - cornerRadii: CGSize(width: Constants.badgeCornerRadii, - height: Constants.badgeCornerRadii))) - let maskLayer = CAShapeLayer() - maskLayer.fillRule = .evenOdd - maskLayer.path = badgeCutoutPath.cgPath - layer.mask = maskLayer + if isRedDot { + redDotView.layer.mask = badgeLabelLayer + } else { + badgeLabel.layer.mask = badgeLabelLayer } + + let computedBadgeWidth = isRedDot ? Constants.redDotWidth : badgeWidth + let badgeBounds = CGRect(x: badgeFrameOriginX(computedBadgeWidth), + y: isRedDot ? redDotVerticalPosition : badgeVerticalPosition, + width: computedBadgeWidth, + height: isRedDot ? Constants.redDotHeight : Constants.badgeHeight) + + + let badgeCutoutPath = UIBezierPath(rect: CGRect(x: badgeBoundsOriginX(computedBadgeWidth), + y: badgeBounds.origin.y, + width: frame.size.width + computedBadgeWidth / 2, + height: frame.size.height)) + // Adding the path for the cutout on the button's titleLabel or imageView where the badge label OR Red Dot will be placed on top of. + badgeCutoutPath.append(UIBezierPath(roundedRect: badgeCutoutRect(for: badgeBounds), + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: viewCornerRadius, + height: viewCornerRadius))) + // Adding the path that will display the badge label with rounded corners on top of the cutout. + badgeCutoutPath.append(UIBezierPath(roundedRect: badgeBounds, + byRoundingCorners: .allCorners, + cornerRadii: CGSize(width: viewCornerRadius, + height: viewCornerRadius))) + let maskLayer = CAShapeLayer() + maskLayer.fillRule = .evenOdd + maskLayer.path = badgeCutoutPath.cgPath + layer.mask = maskLayer } private func badgeCutoutRect(for frame: CGRect) -> CGRect { @@ -212,10 +266,14 @@ class BadgeLabelButton: UIButton { } @objc private func badgeValueDidChange() { - updateBadgeLabel() + updateBadge() updateAccessibilityLabel() } + @objc private func redDotVisibilitDidChanged() { + updateBadge() + } + @objc private func contentSizeCategoryDidChange(notification: Notification) { guard let titleLabel = titleLabel else { return diff --git a/ios/FluentUI/Navigation/UIBarButtonItem+RedDot.swift b/ios/FluentUI/Navigation/UIBarButtonItem+RedDot.swift new file mode 100644 index 0000000000..7f1dc22c24 --- /dev/null +++ b/ios/FluentUI/Navigation/UIBarButtonItem+RedDot.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import UIKit + +@objc public extension UIBarButtonItem { + private struct AssociatedKeys { + static var redDotValue: UInt8 = 0 + } + + static let redDotValueDidChangeNotification = NSNotification.Name(rawValue: "UIBarButtonItemRedDotValueDidChangeNotification") + + /// This Bool indicate if we need to display red dot on top right cornet of button. + /// Red dot will be override by badge value, in case user set for badge value and red dot, it will give preference to badge value. + @objc var shouldShowRedDot: Bool { + get { + return objc_getAssociatedObject(self, &AssociatedKeys.redDotValue) as? Bool ?? false + } + set { + objc_setAssociatedObject(self, &AssociatedKeys.redDotValue, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + NotificationCenter.default.post(name: UIBarButtonItem.redDotValueDidChangeNotification, object: self) + } + } + + /// Use this method on bar button item's instance to set the red to visibility value. + /// - Parameters: + /// - shouldShowRedDot: Bool value indicating if we need to show red dot OR not. + @objc func showRedDot(_ shouldShowRedDot: Bool) { + self.shouldShowRedDot = shouldShowRedDot + } +}