diff --git a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj index 891f5fdc88..9ebc6372bf 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj +++ b/ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj @@ -98,6 +98,7 @@ 92DD1E8D279F496300FDEE0F /* DemoAppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DD1E8C279F496300FDEE0F /* DemoAppearanceView.swift */; }; 92E977B726C7144F008E10A8 /* UIResponder+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92E977B526C713F3008E10A8 /* UIResponder+Extensions.swift */; }; 92E977B826C7144F008E10A8 /* DemoControllerScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92E977B426C713F3008E10A8 /* DemoControllerScrollView.swift */; }; + 92EFD3E42BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92EFD3E32BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift */; }; A589F856211BA71000471C23 /* LabelDemoController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A589F855211BA71000471C23 /* LabelDemoController.swift */; }; A591A3F420F429EB001ED23B /* Demos.swift in Sources */ = {isa = PBXBuildFile; fileRef = A591A3F320F429EB001ED23B /* Demos.swift */; }; A5CEC21020E436F10016922A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEC20F20E436F10016922A /* AppDelegate.swift */; }; @@ -236,6 +237,7 @@ 92E4784B2661AED800BAA058 /* PersonaButtonCarouselDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarouselDemoController.swift; sourceTree = ""; }; 92E977B426C713F3008E10A8 /* DemoControllerScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoControllerScrollView.swift; sourceTree = ""; }; 92E977B526C713F3008E10A8 /* UIResponder+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIResponder+Extensions.swift"; sourceTree = ""; }; + 92EFD3E32BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TooltipDemoController_SwiftUI.swift; sourceTree = ""; }; A589F855211BA71000471C23 /* LabelDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelDemoController.swift; sourceTree = ""; }; A591A3F320F429EB001ED23B /* Demos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demos.swift; sourceTree = ""; }; A5961FA8218A61BB00E2A506 /* PopupMenuDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuDemoController.swift; sourceTree = ""; }; @@ -571,6 +573,7 @@ EC98E2B72992FE6900B9DF91 /* TextFieldObjCDemoController.h */, EC98E2B52992FE5000B9DF91 /* TextFieldObjCDemoController.m */, FD7DF06121FB941400857267 /* TooltipDemoController.swift */, + 92EFD3E32BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift */, 66963D0B29CB792E006F5FA9 /* TwoLineTitleViewDemoController.swift */, 92D5FDFC28AC57650087894B /* TypographyTokensDemoController.swift */, 6FEED93A28A6E5520099D178 /* AliasColorTokensDemoController.swift */, @@ -841,6 +844,7 @@ 497DC2DE24185896008D86F8 /* PillButtonBarDemoController.swift in Sources */, B4EF53C5215C45C400573E8F /* PersonaListViewDemoController.swift in Sources */, A5DCA75E211E3A92005F4CB7 /* DrawerDemoController.swift in Sources */, + 92EFD3E42BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift in Sources */, B4414792228F6F740040E88E /* TableViewCellSampleData.swift in Sources */, 92B45E4E279A1A0B00E72517 /* DemoAppearanceController.swift in Sources */, E6842974247B672000A29C40 /* SceneDelegate.swift in Sources */, diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift index 04244b2d6d..98c5368805 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos.swift @@ -54,7 +54,7 @@ struct Demos { DemoDescriptor("TableViewCell", TableViewCellDemoController.self, supportsVisionOS: true), DemoDescriptor("TableViewHeaderFooterView", TableViewHeaderFooterViewDemoController.self, supportsVisionOS: true), DemoDescriptor("Text Field", TextFieldDemoController.self, supportsVisionOS: false), - DemoDescriptor("Tooltip", TooltipDemoController.self, supportsVisionOS: false), + DemoDescriptor("Tooltip", TooltipDemoController.self, supportsVisionOS: true), DemoDescriptor("TwoLineTitleView", TwoLineTitleViewDemoController.self, supportsVisionOS: false) ] diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController.swift index e095e1fd34..c3f138f66a 100644 --- a/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController.swift +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController.swift @@ -19,6 +19,7 @@ class TooltipDemoController: DemoController { navigationItem.titleView = titleView navigationItem.rightBarButtonItems?.append(UIBarButtonItem(title: "Show on title", style: .plain, target: self, action: #selector(showTitleTooltip))) + container.addArrangedSubview(createButton(title: "Show SwiftUI Demo", action: #selector(showSwiftUIDemo))) container.addArrangedSubview(createButton(title: "Show single-line tooltip below", action: #selector(showSingleTooltipBelow))) container.addArrangedSubview(createButton(title: "Show double-line tooltip above", action: #selector(showDoubleTooltipAbove))) container.addArrangedSubview(createButton(title: "Show tooltip with title above", action: #selector(showTooltipWithTitle))) @@ -92,6 +93,11 @@ class TooltipDemoController: DemoController { return container } + @objc func showSwiftUIDemo() { + navigationController?.pushViewController(TooltipDemoControllerSwiftUI(), + animated: true) + } + @objc func showTitleTooltip(sender: UIBarButtonItem) { Tooltip.shared.show(with: "This is a title-based tooltip.", for: titleView, preferredArrowDirection: .up) } diff --git a/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController_SwiftUI.swift b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController_SwiftUI.swift new file mode 100644 index 0000000000..4a093bfca7 --- /dev/null +++ b/ios/FluentUI.Demo/FluentUI.Demo/Demos/TooltipDemoController_SwiftUI.swift @@ -0,0 +1,133 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import FluentUI +import SwiftUI +import UIKit + +class TooltipDemoControllerSwiftUI: DemoHostingController { + init() { + super.init(rootView: AnyView(TooltipDemoView()), title: "Tooltip (SwiftUI)") + } + + @objc required dynamic init?(coder aDecoder: NSCoder) { + preconditionFailure("init(coder:) has not been implemented") + } + + @MainActor required dynamic init(rootView: AnyView) { + preconditionFailure("init(rootView:) has not been implemented") + } +} + +struct TooltipDemoView: View { + var body: some View { + VStack { + tooltipAnchor + demoOptions + } + } + + @ViewBuilder + private var tooltipAnchor: some View { + Button(action: { + showTooltip = true + }, label: { + Text("Tap for Tooltip") + }) + .buttonStyle(FluentButtonStyle(style: .accent)) + .controlSize(.large) + .fixedSize() + .fluentTooltip(message: tooltipMessage, + title: (tooltipTitle != "") ? tooltipTitle : nil, + preferredArrowDirection: arrowDirection, + offset: offset, + dismissMode: dismissMode, + isPresented: $showTooltip) + .padding(GlobalTokens.spacing(.size560)) + } + + @ViewBuilder + private var demoOptions: some View { + Form { + Section("Content") { + HStack(alignment: .firstTextBaseline) { + Text("Title") + Spacer() + TextField("Title", text: $tooltipTitle) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + .frame(maxWidth: .infinity) + + HStack(alignment: .firstTextBaseline) { + Text("Message") + Spacer() + TextField("Message", text: $tooltipMessage) + .autocapitalization(.none) + .disableAutocorrection(true) + .multilineTextAlignment(.trailing) + } + .frame(maxWidth: .infinity) + } + + Section("Layout") { + Picker("Dismiss Mode", selection: $dismissMode) { + ForEach(Array(Tooltip.DismissMode.allCases.enumerated()), id: \.element) { _, dismissMode in + Text("\(dismissMode.description)").tag(dismissMode) + } + } + + Picker("Arrow Direction", selection: $arrowDirection) { + ForEach(Array(Tooltip.ArrowDirection.allCases.enumerated()), id: \.element) { _, direction in + Text("\(direction.description)").tag(direction) + } + } + + FluentUIDemoToggle(titleKey: "Use offset for origin", isOn: $useOffset) + } + } + } + + private var offset: CGPoint { + useOffset ? .init(x: 20, y: 20) : .zero + } + + @State private var showTooltip: Bool = true + + @State private var tooltipTitle: String = "" + @State private var tooltipMessage: String = "Tooltip message" + @State private var arrowDirection: Tooltip.ArrowDirection = .down + @State private var dismissMode: Tooltip.DismissMode = .tapAnywhere + @State private var useOffset: Bool = false +} + +private extension Tooltip.ArrowDirection { + var description: String { + switch self { + case .up: + return "Up" + case .down: + return "Down" + case .left: + return "Left" + case .right: + return "Right" + } + } +} + +private extension Tooltip.DismissMode { + var description: String { + switch self { + case .tapAnywhere: + return "Tap anywhere" + case .tapOnTooltip: + return "Tap on Tooltip" + case .tapOnTooltipOrAnchor: + return "Tap on Tooltip or Anchor" + } + } +} diff --git a/ios/FluentUI.xcodeproj/project.pbxproj b/ios/FluentUI.xcodeproj/project.pbxproj index 5f7ac49e86..38f78d4a41 100644 --- a/ios/FluentUI.xcodeproj/project.pbxproj +++ b/ios/FluentUI.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ 929DD25A266ED3B600E8175E /* PersonaButtonCarousel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929DD258266ED3B600E8175E /* PersonaButtonCarousel.swift */; }; 929F2ACF2BB77ED100683EA8 /* FluentButtonToggleStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 929F2ACE2BB77ED100683EA8 /* FluentButtonToggleStyle.swift */; }; 92A1E4F526A791590007ED60 /* MSFCardNudge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92A1E4F326A791590007ED60 /* MSFCardNudge.swift */; }; + 92B2E2352BD71F27005D42C4 /* TooltipModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B2E2342BD71F27005D42C4 /* TooltipModifiers.swift */; }; 92B7E6A326864AE900EFC15E /* MSFPersonaButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92B7E6A12684262900EFC15E /* MSFPersonaButton.swift */; }; 92D49054278FF4E50085C018 /* PersonaButtonCarouselModifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D49053278FF4E50085C018 /* PersonaButtonCarouselModifiers.swift */; }; 92D5598226A0FD2800328FD3 /* CardNudge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92D5598026A0FD2800328FD3 /* CardNudge.swift */; }; @@ -370,6 +371,7 @@ 929DD258266ED3B600E8175E /* PersonaButtonCarousel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarousel.swift; sourceTree = ""; }; 929F2ACE2BB77ED100683EA8 /* FluentButtonToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FluentButtonToggleStyle.swift; sourceTree = ""; }; 92A1E4F326A791590007ED60 /* MSFCardNudge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSFCardNudge.swift; sourceTree = ""; }; + 92B2E2342BD71F27005D42C4 /* TooltipModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipModifiers.swift; sourceTree = ""; }; 92B7E6A12684262900EFC15E /* MSFPersonaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSFPersonaButton.swift; sourceTree = ""; }; 92D49053278FF4E50085C018 /* PersonaButtonCarouselModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarouselModifiers.swift; sourceTree = ""; }; 92D5598026A0FD2800328FD3 /* CardNudge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNudge.swift; sourceTree = ""; }; @@ -1309,6 +1311,7 @@ isa = PBXGroup; children = ( FD7DF05B21FA7F5000857267 /* Tooltip.swift */, + 92B2E2342BD71F27005D42C4 /* TooltipModifiers.swift */, 4B8245D7293FC7A200CF0C77 /* TooltipTokenSet.swift */, FD7DF05D21FA7FC100857267 /* TooltipView.swift */, FD7DF05F21FA83C900857267 /* TooltipViewController.swift */, @@ -1720,6 +1723,7 @@ 80AECC22263339E5005AF2F3 /* BottomSheetPassthroughView.swift in Sources */, 925728F9276D6B5800EE1019 /* FontInfo.swift in Sources */, 5314E1CD25F01B730099271A /* AnimationSynchronizer.swift in Sources */, + 92B2E2352BD71F27005D42C4 /* TooltipModifiers.swift in Sources */, 92088EF92666DB2C003F571A /* PersonaButton.swift in Sources */, 9231491428BF026A001B033E /* MSFHeadsUpDisplay.swift in Sources */, 5314E13425F016370099271A /* PopupMenuItem.swift in Sources */, diff --git a/ios/FluentUI/Tooltip/Tooltip.swift b/ios/FluentUI/Tooltip/Tooltip.swift index f40cb10eaf..61396e0ccb 100644 --- a/ios/FluentUI/Tooltip/Tooltip.swift +++ b/ios/FluentUI/Tooltip/Tooltip.swift @@ -229,7 +229,7 @@ open class Tooltip: NSObject, TokenizedControlInternal { } @objc(MSFTooltipArrowDirection) - public enum ArrowDirection: Int { + public enum ArrowDirection: Int, CaseIterable { case up, down, left, right var isVertical: Bool { @@ -256,7 +256,7 @@ open class Tooltip: NSObject, TokenizedControlInternal { } @objc(MSFTooltipDismissMode) - public enum DismissMode: Int { + public enum DismissMode: Int, CaseIterable { case tapAnywhere case tapOnTooltip case tapOnTooltipOrAnchor diff --git a/ios/FluentUI/Tooltip/TooltipModifiers.swift b/ios/FluentUI/Tooltip/TooltipModifiers.swift new file mode 100644 index 0000000000..f4cbbc2670 --- /dev/null +++ b/ios/FluentUI/Tooltip/TooltipModifiers.swift @@ -0,0 +1,133 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +// + +import SwiftUI + +public extension View { + + /// Displays a tooltip based on the current settings, pointing to the `View` being modified. + /// If another tooltip view is already showing, it will be dismissed and the new tooltip will be shown. + /// + /// - Parameters: + /// - message: The text to be displayed on the new tooltip view. + /// - title: The optional bolded text to be displayed above the message on the new tooltip view. + /// - preferredArrowDirection: The preferrred direction for the tooltip's arrow. Only the arrow's axis is guaranteed; the direction may be changed based on available space between the anchorView and the screen's margins. Defaults to down. + /// - offset: An offset from the tooltip's default position. + /// - dismissMode: The mode of tooltip dismissal. Defaults to tapping anywhere. + /// - isPresented: A binding to a Boolean value that determines whether to present the tooltip. When the user dismisses the tooltip, this value is set to `false`. + /// - onTap: An optional closure used to do work after the user taps + @ViewBuilder + func fluentTooltip(message: String, + title: String? = nil, + preferredArrowDirection: Tooltip.ArrowDirection = .down, + offset: CGPoint = CGPoint(x: 0, y: 0), + dismissMode: Tooltip.DismissMode = .tapAnywhere, + isPresented: Binding, + onTap: (() -> Void)? = nil) -> some View { + // Package up all the values to pass through. + let values = TooltipAnchorViewValues( + message: message, + title: title, + preferredArrowDirection: preferredArrowDirection, + offset: offset, + dismissMode: dismissMode, + onTap: onTap) + + self.modifier( + TooltipModifier( + values: values, + isPresented: isPresented + ) + ) + } +} + +// MARK: - Private support for public modifiers + +/// Convenience wrapper for the values used to show a `Tooltip`. +private struct TooltipAnchorViewValues { + let message: String + let title: String? + let preferredArrowDirection: Tooltip.ArrowDirection + let offset: CGPoint + let dismissMode: Tooltip.DismissMode + let onTap: (() -> Void)? +} + +private struct TooltipModifier: ViewModifier { + let values: TooltipAnchorViewValues + @Binding var isPresented: Bool + + func body(content: Content) -> some View { + content + .background { + TooltipAnchorViewRepresentable(values: values, isPresented: $isPresented) + } + } +} + +/// `UIView` subclass that serves as an anchor to the `Tooltip`. +/// +/// Our existing `Tooltip` logic is built entirely around `UIView` anchoring. To reuse this in SwiftUI, we create +/// a simple `UIView` that acts as this anchor. +private class TooltipAnchorView: UIView { + var values: TooltipAnchorViewValues + var isPresented: Binding + + init(values: TooltipAnchorViewValues, isPresented: Binding) { + self.values = values + self.isPresented = isPresented + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + super.didMoveToWindow() + + // It's possible that we were asked to show the tooltip before we had loaded into a window. + // Check again now, just to be safe. + showTooltipIfPossible() + } + + func showTooltipIfPossible() { + if isPresented.wrappedValue && window != nil { + Tooltip.shared.show(with: values.message, + title: values.title, + for: self, + preferredArrowDirection: values.preferredArrowDirection, + offset: values.offset, + dismissOn: values.dismissMode, + onTap: { [weak self, values] in + values.onTap?() + + // Set the `isPresented` binding back to `false` once the tooltip dismisses. + self?.isPresented.wrappedValue = false + }) + } + } +} + +/// Subclass of `UIViewRepresentable` that creates the `TooltipAnchorView`. +private struct TooltipAnchorViewRepresentable: UIViewRepresentable { + var values: TooltipAnchorViewValues + @Binding var isPresented: Bool + + func makeUIView(context: Self.Context) -> TooltipAnchorView { + let view = TooltipAnchorView(values: values, isPresented: $isPresented) + return view + } + + func updateUIView(_ uiView: TooltipAnchorView, context: Context) { + uiView.values = values + if isPresented { + uiView.showTooltipIfPossible() + } else { + Tooltip.shared.hide() + } + } +}