Skip to content

Commit

Permalink
SwiftUI Tooltip (microsoft#2010)
Browse files Browse the repository at this point in the history
  • Loading branch information
mischreiber authored Apr 26, 2024
1 parent 54a3e7c commit c1260d9
Show file tree
Hide file tree
Showing 7 changed files with 283 additions and 3 deletions.
4 changes: 4 additions & 0 deletions ios/FluentUI.Demo/FluentUI.Demo.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -236,6 +237,7 @@
92E4784B2661AED800BAA058 /* PersonaButtonCarouselDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarouselDemoController.swift; sourceTree = "<group>"; };
92E977B426C713F3008E10A8 /* DemoControllerScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoControllerScrollView.swift; sourceTree = "<group>"; };
92E977B526C713F3008E10A8 /* UIResponder+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIResponder+Extensions.swift"; sourceTree = "<group>"; };
92EFD3E32BDA28F100DB35F2 /* TooltipDemoController_SwiftUI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TooltipDemoController_SwiftUI.swift; sourceTree = "<group>"; };
A589F855211BA71000471C23 /* LabelDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabelDemoController.swift; sourceTree = "<group>"; };
A591A3F320F429EB001ED23B /* Demos.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Demos.swift; sourceTree = "<group>"; };
A5961FA8218A61BB00E2A506 /* PopupMenuDemoController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupMenuDemoController.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
2 changes: 1 addition & 1 deletion ios/FluentUI.Demo/FluentUI.Demo/Demos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
4 changes: 4 additions & 0 deletions ios/FluentUI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -370,6 +371,7 @@
929DD258266ED3B600E8175E /* PersonaButtonCarousel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarousel.swift; sourceTree = "<group>"; };
929F2ACE2BB77ED100683EA8 /* FluentButtonToggleStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FluentButtonToggleStyle.swift; sourceTree = "<group>"; };
92A1E4F326A791590007ED60 /* MSFCardNudge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSFCardNudge.swift; sourceTree = "<group>"; };
92B2E2342BD71F27005D42C4 /* TooltipModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipModifiers.swift; sourceTree = "<group>"; };
92B7E6A12684262900EFC15E /* MSFPersonaButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSFPersonaButton.swift; sourceTree = "<group>"; };
92D49053278FF4E50085C018 /* PersonaButtonCarouselModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonaButtonCarouselModifiers.swift; sourceTree = "<group>"; };
92D5598026A0FD2800328FD3 /* CardNudge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNudge.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1309,6 +1311,7 @@
isa = PBXGroup;
children = (
FD7DF05B21FA7F5000857267 /* Tooltip.swift */,
92B2E2342BD71F27005D42C4 /* TooltipModifiers.swift */,
4B8245D7293FC7A200CF0C77 /* TooltipTokenSet.swift */,
FD7DF05D21FA7FC100857267 /* TooltipView.swift */,
FD7DF05F21FA83C900857267 /* TooltipViewController.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 2 additions & 2 deletions ios/FluentUI/Tooltip/Tooltip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
133 changes: 133 additions & 0 deletions ios/FluentUI/Tooltip/TooltipModifiers.swift
Original file line number Diff line number Diff line change
@@ -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<Bool>,
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<Bool>

init(values: TooltipAnchorViewValues, isPresented: Binding<Bool>) {
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()
}
}
}

0 comments on commit c1260d9

Please sign in to comment.