Improvements to the Continue Reading section, add long press actions

This commit is contained in:
Jackson Harper
2023-07-06 14:31:19 -07:00
parent f3bc4ba054
commit e4b86ecf34
8 changed files with 380 additions and 200 deletions

View File

@ -15,9 +15,10 @@ struct LibraryFeatureCardNavigationLink: View {
@EnvironmentObject var audioController: AudioController
let item: LinkedItem
@ObservedObject var viewModel: HomeFeedViewModel
let onLongPress: (LinkedItem) -> Void
var body: some View {
ZStack {
Button {
@ -29,11 +30,11 @@ struct LibraryFeatureCardNavigationLink: View {
}
.opacity(0)
.buttonStyle(PlainButtonStyle())
.onAppear {
Task { await viewModel.itemAppeared(item: item, dataService: dataService) }
}
LibraryFeatureCard(item: item, viewer: dataService.currentViewer)
}
.delayedGesture(LongPressGesture().onEnded { _ in
onLongPress(item)
})
}
}
}

View File

@ -28,6 +28,7 @@ struct AnimatingCellHeight: AnimatableModifier {
@State var searchPresented = false
@State var addLinkPresented = false
@State var settingsPresented = false
@EnvironmentObject var dataService: DataService
@EnvironmentObject var audioController: AudioController
@ -260,6 +261,8 @@ struct AnimatingCellHeight: AnimatableModifier {
@State private var itemToRemove: LinkedItem?
@State private var confirmationShown = false
@State private var showHideFeatureAlert = false
@State var showFeatureActions = false
@State var selectedFeatureItem: LinkedItem?
@ObservedObject var viewModel: HomeFeedViewModel
@ -367,17 +370,17 @@ struct AnimatingCellHeight: AnimatableModifier {
HStack {
Menu(content: {
Button(action: {
viewModel.updateFeatureFilter(dataService: dataService, filter: .continueReading)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .continueReading)
}, label: {
Text("Continue Reading")
})
Button(action: {
viewModel.updateFeatureFilter(dataService: dataService, filter: .pinned)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}, label: {
Text("Pinned")
})
Button(action: {
viewModel.updateFeatureFilter(dataService: dataService, filter: .newsletters)
viewModel.updateFeatureFilter(context: dataService.viewContext, filter: .newsletters)
}, label: {
Text("Newsletters")
})
@ -405,9 +408,14 @@ struct AnimatingCellHeight: AnimatableModifier {
ScrollView(.horizontal, showsIndicators: false) {
if viewModel.featureItems.count > 0 {
LazyHStack(alignment: .top, spacing: 15) {
Spacer(minLength: 1).frame(width: 1)
ForEach(viewModel.featureItems) { item in
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel)
LibraryFeatureCardNavigationLink(item: item, viewModel: viewModel, onLongPress: { item in
self.selectedFeatureItem = item
self.showFeatureActions = true
})
}
Spacer(minLength: 1).frame(width: 1)
}
.padding(.top, 0)
} else {
@ -419,7 +427,7 @@ struct AnimatingCellHeight: AnimatableModifier {
.fixedSize(horizontal: false, vertical: true)
}
}
}.padding(.horizontal, 15)
}
Color.thBorderColor.frame(maxWidth: .infinity, maxHeight: 0.5)
}
}
@ -449,7 +457,8 @@ struct AnimatingCellHeight: AnimatableModifier {
featureCard
.listRowInsets(.init(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowSeparator(.hidden, edges: .all)
.modifier(AnimatingCellHeight(height: viewModel.featureItems.count > 0 ? 200 : 130))
// .modifier(AnimatingCellHeight(height: viewModel.isLoading || viewModel.featureItems.count > 0 ? 190 : 130))
.modifier(AnimatingCellHeight(height: 190))
}
ForEach(viewModel.items) { item in
@ -497,17 +506,46 @@ struct AnimatingCellHeight: AnimatableModifier {
}
Button(LocalText.cancelGeneric, role: .cancel) { self.showHideFeatureAlert = false }
}
.confirmationDialog("", isPresented: $showFeatureActions) {
if let item = selectedFeatureItem {
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .pinned {
Button("Unpin", action: {
viewModel.unpinItem(dataService: dataService, item: item)
})
}
Button("Pin", action: {
viewModel.pinItem(dataService: dataService, item: item)
})
Button("Archive", action: {
viewModel.setLinkArchived(dataService: dataService, objectID: item.objectID, archived: true)
})
Button("Delete", action: {
viewModel.removeLink(dataService: dataService, objectID: item.objectID)
})
if FeaturedItemFilter(rawValue: viewModel.featureFilter) == .continueReading {
Button("Mark Read", action: {
viewModel.markRead(dataService: dataService, item: item)
})
Button("Mark Unread", action: {
viewModel.markUnread(dataService: dataService, item: item)
})
}
Button("Dismiss", role: .cancel, action: {
showFeatureActions = false
})
}
}
}
func swipeActionButton(action: SwipeAction, item: LinkedItem) -> AnyView {
switch action {
case .pin:
return AnyView(Button(action: {
viewModel.addLabel(dataService: dataService, item: item, label: "Pinned")
viewModel.pinItem(dataService: dataService, item: item)
}, label: {
VStack {
Image(systemName: "pin.fill")
.rotationEffect(Angle(degrees: 90))
.rotationEffect(Angle(degrees: 180))
Text("Pin")
}
}).tint(Color(hex: "#0A84FF")))

View File

@ -55,31 +55,25 @@ import Views
super.init()
}
func setItems(_ items: [LinkedItem]) {
func setItems(_ context: NSManagedObjectContext, _ items: [LinkedItem]) {
self.items = items
updateFeatureFilter(context: context, filter: FeaturedItemFilter(rawValue: featureFilter))
}
func updateFeatureFilter(dataService: DataService, filter: FeaturedItemFilter?) {
func updateFeatureFilter(context: NSManagedObjectContext, filter: FeaturedItemFilter?) {
Task {
if let filter = filter {
featureItems = await loadFeatureItems(dataService: dataService, predicate: filter.predicate, sort: filter.sortDescriptor)
print("updated feature items: ", featureItems.count)
featureFilter = filter.rawValue
featureItems = await loadFeatureItems(
context: context,
predicate: filter.predicate,
sort: filter.sortDescriptor
)
} else {
featureItems = []
}
}
if let filter = filter {
// now try to update the continue reading items:
featureItems = (items.filter { item in
filter.predicate.evaluate(with: item)
} as NSArray)
.sortedArray(using: [filter.sortDescriptor])
.compactMap { $0 as? LinkedItem }
featureFilter = filter.rawValue
} else {
featureItems = []
}
}
func handleReaderItemNotification(objectID: NSManagedObjectID, dataService: DataService) {
@ -204,9 +198,9 @@ import Views
// Don't use FRC for searching. Use server results directly.
if fetchedResultsController != nil {
fetchedResultsController = nil
setItems([])
setItems(dataService.viewContext, [])
}
setItems(isRefresh ? newItems : items + newItems)
setItems(dataService.viewContext, isRefresh ? newItems : items + newItems)
}
isLoading = false
@ -239,7 +233,7 @@ import Views
updateFetchController(dataService: dataService)
}
updateFeatureFilter(dataService: dataService, filter: FeaturedItemFilter(rawValue: featureFilter))
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
isLoading = false
showLoadingBar = false
@ -255,13 +249,13 @@ import Views
showLoadingBar = false
}
func loadFeatureItems(dataService: DataService, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] {
func loadFeatureItems(context: NSManagedObjectContext, predicate: NSPredicate, sort: NSSortDescriptor) async -> [LinkedItem] {
let fetchRequest: NSFetchRequest<Models.LinkedItem> = LinkedItem.fetchRequest()
fetchRequest.fetchLimit = 25
fetchRequest.predicate = predicate
fetchRequest.sortDescriptors = [sort]
return (try? dataService.viewContext.fetch(fetchRequest)) ?? []
return (try? context.fetch(fetchRequest)) ?? []
}
private var fetchRequest: NSFetchRequest<Models.LinkedItem> {
@ -315,7 +309,7 @@ import Views
fetchedResultsController.delegate = self
try? fetchedResultsController.performFetch()
setItems(fetchedResultsController.fetchedObjects ?? [])
setItems(dataService.viewContext, fetchedResultsController.fetchedObjects ?? [])
}
func setLinkArchived(dataService: DataService, objectID: NSManagedObjectID, archived: Bool) {
@ -331,12 +325,52 @@ import Views
func addLabel(dataService: DataService, item: LinkedItem, label: String) {
Task {
if let label = LinkedItemLabel.named(label, inContext: dataService.viewContext) {
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: [label.unwrappedID])
// Label already exists, so just add it and refresh everything
let existingLabels = item.labels?.allObjects.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: existingLabels + [label.unwrappedID])
item.update(inContext: dataService.viewContext)
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
} else if let labelID = try? await dataService.createLabel(name: "Pinned", color: "#0A84FF", description: ""),
let label = LinkedItemLabel.named(label, inContext: dataService.viewContext)
{
let existingLabels = item.labels?.allObjects.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: existingLabels + [label.unwrappedID])
item.update(inContext: dataService.viewContext)
updateFeatureFilter(context: dataService.viewContext, filter: FeaturedItemFilter(rawValue: featureFilter))
}
updateFeatureFilter(dataService: dataService, filter: FeaturedItemFilter(rawValue: featureFilter))
}
}
func removeLabel(dataService: DataService, item: LinkedItem, named: String) {
let labelIds = item.labels?.filter { ($0 as? LinkedItemLabel)?.name != named }.compactMap { ($0 as? LinkedItemLabel)?.unwrappedID } ?? []
dataService.updateItemLabels(itemID: item.unwrappedID, labelIDs: labelIds)
item.update(inContext: dataService.viewContext)
}
func pinItem(dataService: DataService, item: LinkedItem) {
addLabel(dataService: dataService, item: item, label: "Pinned")
if featureFilter == FeaturedItemFilter.pinned.rawValue {
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}
}
func unpinItem(dataService: DataService, item: LinkedItem) {
removeLabel(dataService: dataService, item: item, named: "Pinned")
if featureFilter == FeaturedItemFilter.pinned.rawValue {
updateFeatureFilter(context: dataService.viewContext, filter: .pinned)
}
}
func markRead(dataService: DataService, item: LinkedItem) {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 100, anchorIndex: 0)
}
func markUnread(dataService: DataService, item: LinkedItem) {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0, anchorIndex: 0)
}
func snoozeUntil(dataService: DataService, linkId: String, until: Date, successMessage: String?) async {
isLoading = true
@ -398,6 +432,6 @@ import Views
extension HomeFeedViewModel: NSFetchedResultsControllerDelegate {
func controllerDidChangeContent(_ controller: NSFetchedResultsController<NSFetchRequestResult>) {
setItems(controller.fetchedObjects as? [LinkedItem] ?? [])
setItems(controller.managedObjectContext, controller.fetchedObjects as? [LinkedItem] ?? [])
}
}

View File

@ -395,174 +395,171 @@ struct WebReaderContainerView: View {
var body: some View {
ZStack {
GeometryReader { geo in
let foo = print("READER", geo.size)
if let articleContent = viewModel.articleContent {
WebReader(
item: item,
articleContent: articleContent,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
if UIDevice.current.userInterfaceIdiom == .phone, $0.absoluteString != item.unwrappedPageURLString {
linkToOpen = $0
displayLinkSheet = true
} else {
safariWebLink = SafariWebLink(id: UUID(), url: $0)
}
#endif
},
tapHandler: tapHandler,
scrollPercentHandler: scrollPercentHandler,
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
navBarVisibilityRatio = $0
},
readerSettingsChangedTransactionID: $readerSettingsChangedTransactionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
showNavBarActionID: $showNavBarActionID,
shareActionID: $shareActionID,
annotation: $annotation,
showBottomBar: $showBottomBar,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.background(ThemeManager.currentBgColor)
.onAppear {
if item.isUnread {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
}
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
Button(action: {
if let linkToOpen = linkToOpen {
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
}
}, label: { Text(LocalText.genericOpen) })
Button(action: {
#if os(iOS)
UIPasteboard.general.string = item.unwrappedPageURLString
#else
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
#endif
showInSnackbar("Link Copied")
}, label: { Text(LocalText.readerCopyLink) })
Button(action: {
if let linkToOpen = linkToOpen {
viewModel.saveLink(dataService: dataService, url: linkToOpen)
}
}, label: { Text(LocalText.readerSave) })
}
#if os(iOS)
.fullScreenCover(item: $safariWebLink) {
SafariView(url: $0.url)
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
Button(LocalText.genericOk, role: .cancel, action: {
errorAlertMessage = nil
showErrorAlertMessage = false
})
}
#if os(iOS)
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false
}
}
#endif
.sheet(isPresented: $showHighlightAnnotationModal) {
NavigationView {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
},
onCancel: {
showHighlightAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
}
.sheet(isPresented: $showHighlightLabelsModal) {
if let highlight = Highlight.lookup(byID: self.annotation, inContext: self.dataService.viewContext) {
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false) { selectedLabels in
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labelIDs: selectedLabels.map(\.unwrappedID),
dataService: dataService)
}
}
}
} else if let errorMessage = viewModel.errorMessage {
VStack {
Text(errorMessage).padding()
if viewModel.allowRetry, viewModel.hasOriginalUrl(item) {
Button("Open Original", action: {
openOriginalURL(urlString: item.pageURLString)
}).buttonStyle(RoundedRectButtonStyle())
if let urlStr = item.pageURLString, let username = dataService.currentViewer?.username, let url = URL(string: urlStr) {
Button("Attempt to Save Again", action: {
viewModel.errorMessage = nil
viewModel.saveLinkAndFetch(dataService: dataService, username: username, url: url)
}).buttonStyle(RoundedRectButtonStyle())
}
}
}
} else {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
}
}
.task {
if let username = dataService.currentViewer?.username {
await viewModel.loadContent(
dataService: dataService,
username: username,
itemID: item.unwrappedID
)
if let articleContent = viewModel.articleContent {
WebReader(
item: item,
articleContent: articleContent,
openLinkAction: {
#if os(macOS)
NSWorkspace.shared.open($0)
#elseif os(iOS)
if UIDevice.current.userInterfaceIdiom == .phone, $0.absoluteString != item.unwrappedPageURLString {
linkToOpen = $0
displayLinkSheet = true
} else {
viewModel.errorMessage = "You are not logged in."
safariWebLink = SafariWebLink(id: UUID(), url: $0)
}
#endif
},
tapHandler: tapHandler,
scrollPercentHandler: scrollPercentHandler,
webViewActionHandler: webViewActionHandler,
navBarVisibilityRatioUpdater: {
navBarVisibilityRatio = $0
},
readerSettingsChangedTransactionID: $readerSettingsChangedTransactionID,
annotationSaveTransactionID: $annotationSaveTransactionID,
showNavBarActionID: $showNavBarActionID,
shareActionID: $shareActionID,
annotation: $annotation,
showBottomBar: $showBottomBar,
showHighlightAnnotationModal: $showHighlightAnnotationModal
)
.background(ThemeManager.currentBgColor)
.onAppear {
if item.isUnread {
dataService.updateLinkReadingProgress(itemID: item.unwrappedID, readingProgress: 0.1, anchorIndex: 0)
}
Task {
await audioController.preload(itemIDs: [item.unwrappedID])
}
}
.confirmationDialog(linkToOpen?.absoluteString ?? "", isPresented: $displayLinkSheet) {
Button(action: {
if let linkToOpen = linkToOpen {
safariWebLink = SafariWebLink(id: UUID(), url: linkToOpen)
}
}, label: { Text(LocalText.genericOpen) })
Button(action: {
#if os(iOS)
UIPasteboard.general.string = item.unwrappedPageURLString
#else
// Pasteboard.general.string = item.unwrappedPageURLString TODO: fix for mac
#endif
showInSnackbar("Link Copied")
}, label: { Text(LocalText.readerCopyLink) })
Button(action: {
if let linkToOpen = linkToOpen {
viewModel.saveLink(dataService: dataService, url: linkToOpen)
}
}, label: { Text(LocalText.readerSave) })
}
#if os(iOS)
VStack(spacing: 0) {
navBar
Spacer()
if showBottomBar {
bottomButtons
.frame(height: 48)
.background(Color.webControlButtonBackground)
.cornerRadius(6)
.padding(.bottom, 34)
.shadow(color: .gray.opacity(0.13), radius: 8, x: 0, y: 4)
.opacity(bottomBarOpacity)
.onAppear {
withAnimation(Animation.linear(duration: 0.25)) { self.bottomBarOpacity = 1 }
}
.onDisappear {
self.bottomBarOpacity = 0
}
.fullScreenCover(item: $safariWebLink) {
SafariView(url: $0.url)
}
#endif
.alert(errorAlertMessage ?? LocalText.readerError, isPresented: $showErrorAlertMessage) {
Button(LocalText.genericOk, role: .cancel, action: {
errorAlertMessage = nil
showErrorAlertMessage = false
})
}
#if os(iOS)
.formSheet(isPresented: $showRecommendSheet) {
let highlightCount = item.highlights.asArray(of: Highlight.self).filter(\.createdByMe).count
NavigationView {
RecommendToView(
dataService: dataService,
viewModel: RecommendToViewModel(pageID: item.unwrappedID,
highlightCount: highlightCount)
)
}.onDisappear {
showRecommendSheet = false
}
}
#endif
.sheet(isPresented: $showHighlightAnnotationModal) {
NavigationView {
HighlightAnnotationSheet(
annotation: $annotation,
onSave: {
annotationSaveTransactionID = UUID()
},
onCancel: {
showHighlightAnnotationModal = false
},
errorAlertMessage: $errorAlertMessage,
showErrorAlertMessage: $showErrorAlertMessage
)
}
}
.sheet(isPresented: $showHighlightLabelsModal) {
if let highlight = Highlight.lookup(byID: self.annotation, inContext: self.dataService.viewContext) {
ApplyLabelsView(mode: .highlight(highlight), isSearchFocused: false) { selectedLabels in
viewModel.setLabelsForHighlight(highlightID: highlight.unwrappedID,
labelIDs: selectedLabels.map(\.unwrappedID),
dataService: dataService)
}
}
}
} else if let errorMessage = viewModel.errorMessage {
VStack {
Text(errorMessage).padding()
if viewModel.allowRetry, viewModel.hasOriginalUrl(item) {
Button("Open Original", action: {
openOriginalURL(urlString: item.pageURLString)
}).buttonStyle(RoundedRectButtonStyle())
if let urlStr = item.pageURLString, let username = dataService.currentViewer?.username, let url = URL(string: urlStr) {
Button("Attempt to Save Again", action: {
viewModel.errorMessage = nil
viewModel.saveLinkAndFetch(dataService: dataService, username: username, url: url)
}).buttonStyle(RoundedRectButtonStyle())
}
}
}
} else {
ProgressView()
.opacity(progressViewOpacity)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(1000)) {
progressViewOpacity = 1
}
}
.task {
if let username = dataService.currentViewer?.username {
await viewModel.loadContent(
dataService: dataService,
username: username,
itemID: item.unwrappedID
)
} else {
viewModel.errorMessage = "You are not logged in."
}
}
}
#if os(iOS)
VStack(spacing: 0) {
navBar
Spacer()
if showBottomBar {
bottomButtons
.frame(height: 48)
.background(Color.webControlButtonBackground)
.cornerRadius(6)
.padding(.bottom, 34)
.shadow(color: .gray.opacity(0.13), radius: 8, x: 0, y: 4)
.opacity(bottomBarOpacity)
.onAppear {
withAnimation(Animation.linear(duration: 0.25)) { self.bottomBarOpacity = 1 }
}
.onDisappear {
self.bottomBarOpacity = 0
}
}
}
#endif
}
#if os(macOS)
.onReceive(NSNotification.readerSettingsChangedPublisher) { _ in

View File

@ -116,7 +116,7 @@ public extension FeaturedItemFilter {
case .continueReading:
return "Your recently read items will appear here."
case .pinned:
return "Create a label named Pinned and add it to items you'd like to appear here"
return "Create a label named Pinned and add it to items you would like to appear here."
case .recommended:
return "Reads recommended in your Clubs will appear here."
case .newsletters:
@ -172,6 +172,8 @@ public extension FeaturedItemFilter {
switch self {
case .continueReading:
return NSSortDescriptor(key: #keyPath(LinkedItem.readAt), ascending: false)
case .pinned:
return NSSortDescriptor(key: #keyPath(LinkedItem.updatedAt), ascending: false)
default:
return savedAtSort
}

View File

@ -19,6 +19,9 @@ extension DataService {
}
}
linkedItem.update(inContext: self.backgroundContext)
try? self.backgroundContext.save()
// Send update to server
self.syncLabelUpdates(itemID: itemID, labelIDs: labelIDs)
}

View File

@ -18,9 +18,9 @@ public struct LibraryFeatureCard: View {
imageBox
title
Spacer()
}
.padding(0)
.frame(maxWidth: 150)
}.padding(0)
.frame(maxWidth: 150)
}
var isFullyRead: Bool {

View File

@ -0,0 +1,105 @@
//
// FROM: https://github.com/ciaranrobrien/SwiftUIDelayedGesture/tree/main
//
import SwiftUI
public extension View {
/// Sequences a gesture with a long press and attaches the result to the view,
/// which results in the gesture only receiving events after the long press
/// succeeds.
///
/// Use this view modifier *instead* of `.gesture` to delay a gesture:
///
/// ScrollView {
/// FooView()
/// .delayedGesture(someGesture, delay: 0.2)
/// }
///
/// - Parameters:
/// - gesture: A gesture to attach to the view.
/// - mask: A value that controls how adding this gesture to the view
/// affects other gestures recognized by the view and its subviews.
/// - delay: A value that controls the duration of the long press that
/// must elapse before the gesture can be recognized by the view.
/// - action: An action to perform if a tap gesture is recognized
/// before the long press can be recognized by the view.
func delayedGesture<T: Gesture>(_ gesture: T,
including mask: GestureMask = .all,
delay: TimeInterval = 0.25,
onTapGesture action: @escaping () -> Void = {}) -> some View
{
modifier(DelaysTouches(duration: delay, action: action))
.gesture(gesture, including: mask)
}
/// Attaches a long press gesture to the view, which results in gestures with a
/// lower precedence only receiving events after the long press succeeds.
///
/// Use this view modifier *before* `.gesture` to delay a gesture:
///
/// ScrollView {
/// FooView()
/// .delayedInput(delay: 0.2)
/// .gesture(someGesture)
/// }
///
/// - Parameters:
/// - delay: A value that controls the duration of the long press that
/// must elapse before lower precedence gestures can be recognized by
/// the view.
/// - action: An action to perform if a tap gesture is recognized
/// before the long press can be recognized by the view.
func delayedInput(delay: TimeInterval = 0.25,
onTapGesture action: @escaping () -> Void = {}) -> some View
{
modifier(DelaysTouches(duration: delay, action: action))
}
}
private struct DelaysTouches: ViewModifier {
@State private var disabled = false
@State private var touchDownDate: Date? = nil
var duration: TimeInterval
var action: () -> Void
func body(content: Content) -> some View {
Button(action: action) {
content
}
.buttonStyle(DelaysTouchesButtonStyle(disabled: $disabled, duration: duration, touchDownDate: $touchDownDate))
.disabled(disabled)
}
}
private struct DelaysTouchesButtonStyle: ButtonStyle {
@Binding var disabled: Bool
var duration: TimeInterval
@Binding var touchDownDate: Date?
func makeBody(configuration: Configuration) -> some View {
configuration.label
.onChange(of: configuration.isPressed, perform: handleIsPressed)
}
private func handleIsPressed(isPressed: Bool) {
if isPressed {
let date = Date()
touchDownDate = date
DispatchQueue.main.asyncAfter(deadline: .now() + max(duration, 0)) {
if date == touchDownDate {
disabled = true
DispatchQueue.main.async {
disabled = false
}
}
}
} else {
touchDownDate = nil
disabled = false
}
}
}