Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Macos callout text run anchoring #3661

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
40 changes: 28 additions & 12 deletions apps/fluent-tester/src/TestComponents/Callout/CalloutTest.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as React from 'react';
import type { KeyboardMetrics } from 'react-native';
import { Text, View, Switch, ScrollView, Platform } from 'react-native';
import { View, Switch, ScrollView, Platform } from 'react-native';

import { Button, Callout, Separator, Pressable, StealthButton } from '@fluentui/react-native';
import { Button, Callout, Separator, Pressable, StealthButton, TextV1 as Text } from '@fluentui/react-native';
import type { IFocusable, RestoreFocusEvent, DismissBehaviors, ICalloutProps } from '@fluentui/react-native';

import { E2ECalloutTest } from './CalloutE2ETest';
Expand All @@ -15,13 +15,13 @@ import { Test } from '../Test';
const StandardCallout: React.FunctionComponent = () => {
const [showStandardCallout, setShowStandardCallout] = React.useState(false);
const [isStandardCalloutVisible, setIsStandardCalloutVisible] = React.useState(false);
const [openCalloutOnHoverAnchor, setOpenCalloutOnHoverAnchor] = React.useState(true);
const [openCalloutOnHoverAnchor, setOpenCalloutOnHoverAnchor] = React.useState(false);
const [calloutHovered, setCalloutHovered] = React.useState(false);

const [shouldSetInitialFocus, setShouldSetInitialFocus] = React.useState(true);
const onInitialFocusChange = React.useCallback((value: boolean) => setShouldSetInitialFocus(value), []);

const [customRestoreFocus, setCustomRestoreFocus] = React.useState(false);
const [customRestoreFocus, setCustomRestoreFocus] = React.useState(true);
const onRestoreFocusChange = React.useCallback((value) => setCustomRestoreFocus(value), []);

const [isBeakVisible, setIsBeakVisible] = React.useState(false);
Expand Down Expand Up @@ -61,12 +61,17 @@ const StandardCallout: React.FunctionComponent = () => {
[calloutDismissBehaviors],
);

const textRef = React.useRef<Text>(null);
const textRefInner1 = React.useRef<Text>(null);
const textRefInner2 = React.useRef<Text>(null);
const redTargetRef = React.useRef<View>(null);
const blueTargetRef = React.useRef<View>(null);
const greenTargetRef = React.useRef<View>(null);
const decoyBtn1Ref = React.useRef<IFocusable>(null);
const decoyBtn2Ref = React.useRef<IFocusable>(null);
const [anchorRef, setAnchorRef] = React.useState<React.RefObject<View> | undefined>(redTargetRef);
const [anchorRefIndex, setAnchorRefIndex] = React.useState(0);
const anchorRefCycle = [redTargetRef, greenTargetRef, blueTargetRef, textRef, textRefInner1, textRefInner2];
const [anchorRef, setAnchorRef] = React.useState<React.RefObject<View> | React.RefObject<Text> | string | undefined>(anchorRefCycle[0]);
const [hoveredTargetsCount, setHoveredTargetsCount] = React.useState(0);
const [displayCountHoveredTargets, setDisplayCountHoveredTargets] = React.useState(0);

Expand Down Expand Up @@ -143,8 +148,9 @@ const StandardCallout: React.FunctionComponent = () => {

const toggleCalloutRef = React.useCallback(() => {
// Cycle the target ref between the RGB target views
setAnchorRef(anchorRef === redTargetRef ? greenTargetRef : anchorRef === greenTargetRef ? blueTargetRef : redTargetRef);
}, [anchorRef]);
setAnchorRefIndex((anchorRefIndex + 1) % anchorRefCycle.length);
setAnchorRef(anchorRefCycle[anchorRefIndex]);
}, [anchorRef, anchorRefIndex]);

const switchTargetRefOrRect = React.useCallback(() => {
// Switch between RGB views or a fixed anchor
Expand Down Expand Up @@ -312,7 +318,7 @@ const StandardCallout: React.FunctionComponent = () => {

<Separator vertical />

<View style={{ flexDirection: 'column', paddingHorizontal: 5 }}>
<View style={{ flexDirection: 'column', paddingHorizontal: 5, maxWidth: 250 }}>
<Pressable
onHoverIn={() => updateCalloutTargetsHoverState(true, redTargetRef)}
onHoverOut={() => updateCalloutTargetsHoverState(false, redTargetRef)}
Expand All @@ -337,6 +343,16 @@ const StandardCallout: React.FunctionComponent = () => {
{anchorRefsInfo.isCurrentAnchor[2] && <Text style={{ color: 'white' }}>{anchorRefsInfo.hoverCount}</Text>}
</View>
</Pressable>
<Text componentRef={textRef}>
{'Complex'}
<Text>
{' text tree'}
<Text style={{fontSize: 16}} componentRef={textRefInner1}>{' [twiceNested]'}</Text>
{' with multiple nested text runs'}
</Text>
<Text style={{fontSize: 20}} componentRef={textRefInner2}>{' [onceNested]'}</Text>
{' and subtext runs'}
</Text>
</View>
</View>

Expand Down Expand Up @@ -468,11 +484,11 @@ const e2eSections: TestSection[] = [

export const CalloutTest: React.FunctionComponent = () => {
const status: PlatformStatus = {
win32Status: 'Production',
win32Status: 'Beta',
uwpStatus: 'Backlog',
iosStatus: 'N/A',
macosStatus: 'Production',
androidStatus: 'N/A',
iosStatus: 'Backlog',
macosStatus: 'Beta',
androidStatus: 'Backlog',
};

const description = 'A callout is an anchored tip that can be used to teach people or guide them through the app without blocking them.';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "minor",
"comment": "CalloutView native changes for MacOS support of anchoring Callouts to nested text runs",
"packageName": "@fluentui-react-native/callout",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "Improve test page for CalloutTest for cross-plat text run anchor testing",
"packageName": "@fluentui-react-native/tester",
"email": "[email protected]",
"dependentChangeType": "patch"
}
183 changes: 171 additions & 12 deletions packages/components/Callout/macos/CalloutView.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,71 @@
import AppKit
import Foundation
#if USE_REACT_AS_MODULE
import React
#endif // USE_REACT_AS_MODULE

/// Return the text length of a provided RCTShadowView
func getLengthOfTextShadowNode(shadowView: RCTShadowView) -> Int {
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
// If it's a RawTextShadowView, the length is simply its text length
if let rawTextView = shadowView as? RCTRawTextShadowView {
if let rawTextLength = rawTextView.text?.count {
return rawTextLength
}

return 0
}

// If it's a BaseTextShadowView, it may have multiple nested texts that
// should be summed together
var sumTextLength = 0
if let baseTextView = shadowView as? RCTBaseTextShadowView {
baseTextView.reactSubviews().forEach { subview in
sumTextLength += getLengthOfTextShadowNode(shadowView: subview)
}
}

return sumTextLength
}

/// Search for the provided reactTag in the provided ShadowView's subtree
/// When successfully found, returns:
/// found: true
/// startCharRange: number of characters in the text string before the reactTag subrange
///
/// When not found, returns:
/// found: false
/// startCharRange: 0
func getStartCharRangeForTag(reactTag: NSNumber, shadowView: RCTShadowView) -> (found: Bool, startCharRange: Int) {
if shadowView.reactTag == reactTag {
// If this shadowView is our target, we're done; return that we found it
return (found: true, startCharRange: 0)
} else if let rawTextShadowView = shadowView as? RCTRawTextShadowView {
// If this shadowView is a rawText view, it has no subviews; return the length of the text to be added
// to the startCharRange index
if let rawTextLength = rawTextShadowView.text?.count {
return (found: false, startCharRange: rawTextLength)
}
} else {
// Otherwise our target view may be a subview; sum the character range for each subview subtree
// before and including the subview that contains our target view
var startCharRange = 0
for subview in shadowView.reactSubviews() {
let subviewSearch = getStartCharRangeForTag(reactTag:reactTag, shadowView: subview)
startCharRange += subviewSearch.startCharRange
if (subviewSearch.found) {
return (found: subviewSearch.found, startCharRange: startCharRange)
}
}

return (found: false, startCharRange: startCharRange)
}

return (found: false, startCharRange: 0)
}

@objc(FRNCalloutView)
class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {

@objc public var target: NSNumber? {
didSet {
let targetView = bridge?.uiManager.view(forReactTag: target)
if (targetView == nil && target != nil) {
preconditionFailure("Invalid target")
}
anchorView = targetView
updateCalloutFrameToAnchor()
}
}
Expand Down Expand Up @@ -175,14 +227,121 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
isCalloutWindowShown = false
}

/// Return the boundingRect for a shadowView that is a subview of a Yoga leaf node
/// The boundingRect is returned relative to the Yoga leaf node
private func getBoundsForSubShadowOfLeafShadow(subShadowView: RCTShadowView, leafShadowView: RCTShadowView) -> NSRect? {
// Do not proceed if the preconditions of this function are not met
guard leafShadowView.isYogaLeafNode() && subShadowView.viewIsDescendant(of: leafShadowView) else {
preconditionFailure("leafshadow is not a leaf TextView or subshadow not a descendant of the leafshadow")
}

// Do not proceed if we don't have a UI Manager; we won't be able to determine the bounding rect
guard let uiManager = bridge?.uiManager else {
return nil
}

// Do not proceed if the leaf shadow is not a root text shadow view, which we're specializing for right now
// In the future this could be generalized for other complex yoga leaf nodes with subviews, such as react-native-svg
guard let textShadowView = leafShadowView as? RCTTextShadowView else {
return nil
}

// Do not proceed if we don't have the NSView corresponding to the yoga leaf view
guard let leafView = uiManager.view(forReactTag: leafShadowView.reactTag) else {
return nil
}

// Do not proceed if somehow the leafView is not an RCTTextView
guard let textView = leafView as? RCTTextView else {
preconditionFailure("it should not be possible for the RCTTextShadowView to not be represented by an RCTTextView")
}
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved

// Search for our target tag and get its startCharRange index in the overall Text view
let startCharRangeSearch = getStartCharRangeForTag(reactTag: subShadowView.reactTag, shadowView: textShadowView)
if (!startCharRangeSearch.found) {
// Did not find our reactTag
return nil
}

// Having found our target, return the bounding rect for its corresponding character range
return textView.getRectForCharRange(NSRange(location: startCharRangeSearch.startCharRange, length: getLengthOfTextShadowNode(shadowView: subShadowView)))
}

/// Get the leaf shadow view corresponding to a leaf Yoga node for the provided ShadowView
private func getLeafShadowViewForShadowView(shadowView: RCTShadowView?) -> RCTShadowView? {
var shadowParentIter = shadowView
while let shadowViewIter = shadowParentIter {
if (shadowViewIter.isYogaLeafNode()) {
return shadowViewIter
}
shadowParentIter = shadowViewIter.superview
}

return nil
}

// Return the anchor rect for the target prop if available
private func getAnchorRectForTarget() -> NSRect? {
guard let reactTag = target, let reactBridge = bridge else {
return nil
}

let zeroRect = CGRect(x: 0, y: 0, width: 0, height: 0)
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
let targetView = reactBridge.uiManager.view(forReactTag: reactTag)

// If the targetView is backed by an NSView and has a representative rect, return it as the anchor rect for the target
if let targetViewBounds = targetView?.bounds, !targetViewBounds.equalTo(zeroRect) {
return calculateAnchorViewScreenRect(anchorView: targetView, anchorBounds: targetViewBounds)
}

// If the targetView could not be found or was not a representative rect, it may be a child of a yoga leaf node e.g. virtualized text
guard let targetShadowView = reactBridge.uiManager.shadowView(forReactTag: target) else {
return nil
}

// Find the leaf ShadowView of our targetView
guard let leafShadowView = getLeafShadowViewForShadowView(shadowView: targetShadowView) else {
return nil
}

// Ensure we have a real NSView for our leaf ShadowView
guard let leafNSView = reactBridge.uiManager.view(forReactTag: leafShadowView.reactTag) else {
return nil
}

// Find the bounding rect of our targetView relative to the leafShadowView
guard let targetViewBounds = getBoundsForSubShadowOfLeafShadow(subShadowView: targetShadowView, leafShadowView: leafShadowView) else {
return nil
}

// If we could find the bounding rect of our target view and it's a representative rect, return it as the anchor rect for the target
if !targetViewBounds.equalTo(zeroRect) {
return calculateAnchorViewScreenRect(anchorView: leafNSView, anchorBounds: targetViewBounds)
}

// Unfortunately our efforts could not determine a valid anchor rect for our target prop
return nil
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
}

/// Get the AnchorScreenRect to use for Callout anchoring, prioritizing the target prop is prioritized over th anchorRect prop
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
private func getAnchorScreenRect() -> NSRect? {
if target != nil {
return getAnchorRectForTarget();
} else {
return calculateAnchorRectScreenRect();
}
}

/// Sets the frame of the Callout Window (in screen coordinates to be off of the Anchor on the preferred edge
private func updateCalloutFrameToAnchor() {
guard window != nil else {
return
}

// Prefer anchorView over anchorRect if available
let anchorScreenRect = anchorView != nil ? calculateAnchorViewScreenRect() : calculateAnchorRectScreenRect()
guard let anchorScreenRect = getAnchorScreenRect() else {
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
return
}

let calloutScreenRect = bestCalloutRect(relativeTo: anchorScreenRect)

// Because we immediately update the rect as props come in, there's a possibility that we have neither
Expand Down Expand Up @@ -224,7 +383,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
}

/// Calculates the NSRect of the anchorView in the coordinate space of the current screen
private func calculateAnchorViewScreenRect() -> NSRect {
private func calculateAnchorViewScreenRect(anchorView: NSView?, anchorBounds: NSRect) -> NSRect {
guard let anchorView = anchorView else {
preconditionFailure("No anchor view provided to position the Callout")
}
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -233,7 +392,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
preconditionFailure("No window found")
}

let anchorBoundsInWindow = anchorView.convert(anchorView.bounds, to: nil)
let anchorBoundsInWindow = anchorView.convert(anchorBounds, to: nil)
let anchorFrameInScreenCoordinates = window.convertToScreen(anchorBoundsInWindow)

return anchorFrameInScreenCoordinates
Expand Down Expand Up @@ -370,7 +529,7 @@ class CalloutView: RCTView, CalloutWindowLifeCycleDelegate {
// MARK: Private variables

/// The view the Callout is presented from.
private var anchorView: NSView?
private var anchorReactTag: NSNumber?
PPatBoyd marked this conversation as resolved.
Show resolved Hide resolved

/// The view we forward Callout's Children to. It's hosted within the CalloutWindow's
/// view hierarchy, ensuring our React Views are not placed in the main window.
Expand Down
Loading