Skip to content

Commit

Permalink
[iOS] Add longpress context menu to begin overflow menu customization
Browse files Browse the repository at this point in the history
The main thing in this CL is the addition of longpress items to
OverflowMenuItem, so then the UI can use them to show a context menu
containing those items.

There are some associated UI changes to make the UI for longpresses
appear better. This adds a custom ContentShape for longpresses so only
the parts of the view in that shape are highlighted when the user
presses.

Demo: https://drive.google.com/file/d/1ifsBhfxCY62rA92fJSYD3k6e9THMibrL/view?usp=sharing&resourcekey=0-YQtFK35l_eHefwMGGgWszg

Bug: 1463959
Change-Id: Ia6022d851b577f1e65a9f9ff86ed56115cb0dd74
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4794949
Reviewed-by: Gauthier Ambard <gambard@chromium.org>
Reviewed-by: Nicolas MacBeth <nicolasmacbeth@google.com>
Commit-Queue: Robbie Gibson <rkgibson@google.com>
Code-Coverage: findit-for-me@appspot.gserviceaccount.com <findit-for-me@appspot.gserviceaccount.com>
Cr-Commit-Position: refs/heads/main@{#1188350}
  • Loading branch information
rkgibson2 authored and Chromium LUCI CQ committed Aug 25, 2023
1 parent 4441cb7 commit 2c4cff9
Show file tree
Hide file tree
Showing 9 changed files with 461 additions and 218 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
// process.
@protocol OverflowMenuCustomizationCommands

- (void)showActionCustomization;
- (void)showMenuCustomization;

- (void)hideActionCustomization;
- (void)hideMenuCustomization;

@end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ class DestinationDragHandler: ObservableObject {
return DestinationListDropDelegate(handler: self)
}

/// Returns a new item provider for the drag interaction.
func newItemProvider(forDestination destination: OverflowMenuDestination) -> NSItemProvider {
let itemProvider = DidEndItemProvider(object: destination.name as NSString)
itemProvider.didEnd = { [weak self] in
self?.endDrag()
}
return itemProvider
}

/// Performs a drop into a list. Should be passed to `List`'s `.onInsert`
/// method.
func performListDrop(index: Int, providers: [NSItemProvider]) {
Expand Down Expand Up @@ -145,3 +154,12 @@ class DestinationDragHandler: ObservableObject {
}
}
}

/// A custom item provider class that calls the provided `didEnd` callback when
/// the drag ends.
class DidEndItemProvider: NSItemProvider {
var didEnd: (() -> Void)?
deinit {
didEnd?()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,26 @@ struct OverflowMenuActionRow: View {
}

var body: some View {
Button(
action: {
guard !isEditing else {
return
button
.accessibilityIdentifier(action.accessibilityIdentifier)
.disabled(!action.enabled || action.enterpriseDisabled)
.if(!isEditing) { view in
view.contextMenu {
ForEach(action.longPressItems) { item in
Section {
Button {
item.handler()
} label: {
Label(item.title, systemImage: item.symbolName)
}
}
}
}
metricsHandler?.popupMenuTookAction()
action.handler()
},
label: {
rowContent
.contentShape(Rectangle())
}
)
.accessibilityIdentifier(action.accessibilityIdentifier)
.disabled(!action.enabled || action.enterpriseDisabled)
.if(!action.useSystemRowColoring) { view in
view.accentColor(.textPrimary)
}
.listRowSeparatorTint(.overflowMenuSeparator)
.if(!action.useSystemRowColoring) { view in
view.accentColor(.textPrimary)
}
.listRowSeparatorTint(.overflowMenuSeparator)
}

@ViewBuilder
Expand Down Expand Up @@ -103,6 +104,26 @@ struct OverflowMenuActionRow: View {
}
}

// The button view, which is replaced by just a plain view when this is in
// edit mode.
@ViewBuilder
var button: some View {
if isEditing {
rowContent
} else {
Button(
action: {
metricsHandler?.popupMenuTookAction()
action.handler()
},
label: {
rowContent
.contentShape(Rectangle())
}
)
}
}

private var name: some View {
Text(action.name).lineLimit(1)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,41 +134,42 @@ struct OverflowMenuDestinationList: View {
// spacing before the first item.
Spacer().frame(width: Constants.iconInitialSpace - spacing.iconSpacing)
ForEach(destinations) { destination in
OverflowMenuDestinationView(
let destinationView = OverflowMenuDestinationView(
destination: destination, layoutParameters: layoutParameters,
highlighted: uiConfiguration.highlightDestination == destination.destination,
metricsHandler: metricsHandler
)
.id(destination.destination)
.ifLet(dragHandler) { view, dragHandler in
view
.onDrag {
dragHandler.startDrag(from: destination)
return NSItemProvider(object: destination.name as NSString)
} preview: {
OverflowMenuDestinationView(
destination: destination, layoutParameters: layoutParameters,
highlighted: uiConfiguration.highlightDestination == destination.destination,
metricsHandler: nil)
}
.onDrop(
of: [.text],
delegate: dragHandler.newDropDelegate(
forDestination: destination))
}
.overlay(alignment: .editButton) {
if editMode?.wrappedValue.isEditing == true && destination.canBeHidden {
DestinationEditButton(destination: destination)
.alignmentGuide(HorizontalAlignment.editButton) {
$0[HorizontalAlignment.center]
let destinationBeingDragged =
dragHandler?.dragOnDestinations ?? false
&& dragHandler?.currentDrag?.item == destination
destinationView
.id(destination.destination)
.ifLet(dragHandler) { view, dragHandler in
view
.opacity(destinationBeingDragged ? 0.01 : 1)
.onDrag {
dragHandler.startDrag(from: destination)
return dragHandler.newItemProvider(forDestination: destination)
}
.alignmentGuide(VerticalAlignment.editButton) { $0[VerticalAlignment.center] }
.onDrop(
of: [.text],
delegate: dragHandler.newDropDelegate(
forDestination: destination))
}
}
.matchedGeometryEffect(
id: MenuCustomizationAnimationID.from(destination), in: namespace
)

.overlay(alignment: .editButton) {
if !destinationBeingDragged && editMode?.wrappedValue.isEditing == true
&& destination.canBeHidden
{
DestinationEditButton(destination: destination)
.alignmentGuide(HorizontalAlignment.editButton) {
$0[HorizontalAlignment.center]
}
.alignmentGuide(VerticalAlignment.editButton) { $0[VerticalAlignment.center] }
}
}
.matchedGeometryEffect(
id: MenuCustomizationAnimationID.from(destination), in: namespace
)
}
}.alignmentGuide(.bottom) { $0[.bottom] + Constants.bottomMargin }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ struct IsPressedStyle: ButtonStyle {
}
}

/// `PreferenceKey` holding the frame of the icon in the destination view.
struct IconFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .null
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = CGRectUnion(value, nextValue())
}
}

/// `PreferenceKey` holding the frame of the text in the destination view.
struct TextFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .null
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = CGRectUnion(value, nextValue())
}
}

/// A view displaying a single destination.
@available(iOS 15, *)
struct OverflowMenuDestinationView: View {
Expand All @@ -34,6 +50,18 @@ struct OverflowMenuDestinationView: View {
case horizontal(itemWidth: CGFloat)
}

/// Shape consisting of a path around the icon and text.
struct IconShape: Shape {
let iconFrame: CGRect
let textFrame: CGRect

func path(in rect: CGRect) -> Path {
var path = Path(roundedRect: iconFrame, cornerRadius: Dimensions.cornerRadius)
path.addRect(textFrame)
return path
}
}

enum AccessibilityIdentifier {
/// The addition to the `accessibilityIdentfier` for this element if it
/// has an error badge.
Expand Down Expand Up @@ -83,6 +111,8 @@ struct OverflowMenuDestinationView: View {
static let newLabelBadgeWidth: CGFloat = 20
}

static let viewNamespace = "destinationView"

/// The destination for this view.
var destination: OverflowMenuDestination

Expand All @@ -95,17 +125,44 @@ struct OverflowMenuDestinationView: View {

@State private var isPressed = false

@State private var iconFrame: CGRect = .zero
@State private var textFrame: CGRect = .zero

weak var metricsHandler: PopupMenuMetricsHandler?

var body: some View {
button
.coordinateSpace(name: Self.viewNamespace)
.contentShape(
[.contextMenuPreview, .dragPreview],
IconShape(iconFrame: iconFrame, textFrame: textFrame)
)
.if(editMode?.wrappedValue.isEditing != true) { view in
view.contextMenu {
ForEach(destination.longPressItems) { item in
Section {
Button {
item.handler()
} label: {
Label(item.title, systemImage: item.symbolName)
}
}
}
}
}
.accessibilityIdentifier(accessibilityIdentifier)
.accessibilityLabel(Text(accessibilityLabel))
.if(highlighted) { view in
view.anchorPreference(
key: OverflowMenuDestinationList.HighlightedDestinationBounds.self, value: .bounds
) { $0 }
}
.onPreferenceChange(IconFramePreferenceKey.self) { newFrame in
iconFrame = newFrame
}
.onPreferenceChange(TextFramePreferenceKey.self) { newFrame in
textFrame = newFrame
}
}

// The button view, which is replaced by just a plain view when this is in
Expand Down Expand Up @@ -186,6 +243,12 @@ struct OverflowMenuDestinationView: View {
let image = (destination.systemSymbol ? Image(systemName: symbolName) : Image(symbolName))
.renderingMode(.template)
return iconBuilder(interiorPadding: interiorPadding, image: image)
.overlay {
GeometryReader { geometry in
Color.clear.preference(
key: IconFramePreferenceKey.self, value: geometry.frame(in: .named(Self.viewNamespace)))
}
}
}

var circleBadge: some View {
Expand Down Expand Up @@ -263,6 +326,12 @@ struct OverflowMenuDestinationView: View {
.padding([.leading, .trailing], textSpacing)
.multilineTextAlignment(.center)
.lineLimit(maximumLines)
.overlay {
GeometryReader { geometry in
Color.clear.preference(
key: TextFramePreferenceKey.self, value: geometry.frame(in: .named(Self.viewNamespace)))
}
}
}

var accessibilityLabel: String {
Expand Down Expand Up @@ -295,5 +364,4 @@ struct OverflowMenuDestinationView: View {
return itemWidth
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import SwiftUI
/// Whether the item is shown or hidden in the menu overall.
@Published public var shown = true

@Published public var longPressItems: [OverflowMenuLongPressItem] = []

public init(
name: String,
symbolName: String?,
Expand All @@ -52,7 +54,18 @@ import SwiftUI
self.displayNewLabelIcon = displayNewLabelIcon
self.handler = handler
}
}

/// Represents the data necessary to add a long press context menu to an item.
@objcMembers public class OverflowMenuLongPressItem: NSObject, ObservableObject {
@Published public var title: String
@Published public var symbolName: String
@Published public var handler: () -> Void
public init(title: String, symbolName: String, handler: @escaping () -> Void) {
self.title = title
self.symbolName = symbolName
self.handler = handler
}
}

// MARK: - Identifiable
Expand All @@ -62,3 +75,9 @@ extension OverflowMenuItem: Identifiable {
return name
}
}

extension OverflowMenuLongPressItem: Identifiable {
public var id: String {
return title
}
}

0 comments on commit 2c4cff9

Please sign in to comment.