mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Implement swipe to reply on timeline items (#1148)
This commit is contained in:
@@ -49,7 +49,7 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeContextMenu_Previews: PreviewProvider {
|
||||
struct LongPressWithFeedback_Previews: PreviewProvider {
|
||||
static var previews: some View { Preview() }
|
||||
|
||||
struct Preview: View {
|
||||
|
||||
@@ -115,6 +115,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
.longPressWithFeedback {
|
||||
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
|
||||
}
|
||||
.swipeRightAction {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
} shouldStartAction: {
|
||||
context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false
|
||||
} action: {
|
||||
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply))
|
||||
}
|
||||
.padding(.top, messageBubbleTopPadding)
|
||||
}
|
||||
|
||||
|
||||
@@ -71,13 +71,15 @@ struct TimelineItemPlainStylerView<Content: View>: View {
|
||||
}
|
||||
// We need a tap gesture before this long one so that it doesn't
|
||||
// steal away the gestures from the scroll view
|
||||
.onLongPressGesture(minimumDuration: 0.25) {
|
||||
.longPressWithFeedback {
|
||||
context.send(viewAction: .timelineItemMenu(itemID: timelineItem.id))
|
||||
feedbackGenerator.impactOccurred()
|
||||
} onPressingChanged: { pressing in
|
||||
if pressing {
|
||||
feedbackGenerator.prepare()
|
||||
}
|
||||
}
|
||||
.swipeRightAction {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
} shouldStartAction: {
|
||||
context.viewState.timelineItemMenuActionProvider?(timelineItem.id)?.canReply ?? false
|
||||
} action: {
|
||||
context.send(viewAction: .timelineItemMenuAction(itemID: timelineItem.id, action: .reply))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SwipeRightAction<Label: View>: ViewModifier {
|
||||
private let actionThreshold = 50.0
|
||||
private let xOffsetThreshold = 100.0
|
||||
private let swipeThreshold = 1000.0
|
||||
|
||||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
|
||||
|
||||
@State private var canStartAction = false
|
||||
@GestureState private var dragGestureActive = false
|
||||
|
||||
@State private var hasReachedActionThreshold = false
|
||||
@State private var xOffset = 0.0
|
||||
|
||||
/// The view to be shown on the left side of the content
|
||||
let label: () -> Label
|
||||
/// Defer computing whether an action is available until the gesture is started
|
||||
let shouldStartAction: () -> Bool
|
||||
/// Callback for when the dragged past the action threshold
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.offset(x: xOffset, y: 0.0)
|
||||
.animation(.interactiveSpring().speed(0.5), value: xOffset)
|
||||
.gesture(DragGesture()
|
||||
.updating($dragGestureActive) { _, state, _ in
|
||||
// Available actions should be computed on the fly so we use a gesture state change
|
||||
// to ask whether the move should be started or not.
|
||||
state = true
|
||||
}
|
||||
.onChanged { value in
|
||||
guard canStartAction else {
|
||||
return
|
||||
}
|
||||
|
||||
// We want to add a spring like behavior to the drag in which the view
|
||||
// moves slower the more it's dragged. We use a circular easing function
|
||||
// to generate those values up to the `swipeThreshold`
|
||||
// The final translation will be between 0 and `swipeThreshold` with the action being enabled from
|
||||
// `actionThreshold` onwards
|
||||
let screenWidthNormalisedTranslation = max(0.0, min(value.translation.width, swipeThreshold)) / swipeThreshold
|
||||
let easedTranslation = circularEaseOut(screenWidthNormalisedTranslation)
|
||||
xOffset = easedTranslation * xOffsetThreshold
|
||||
|
||||
if xOffset > actionThreshold {
|
||||
if !hasReachedActionThreshold {
|
||||
feedbackGenerator.impactOccurred()
|
||||
hasReachedActionThreshold = true
|
||||
}
|
||||
} else {
|
||||
hasReachedActionThreshold = false
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if xOffset > actionThreshold {
|
||||
action()
|
||||
}
|
||||
|
||||
xOffset = 0.0
|
||||
}
|
||||
)
|
||||
.onChange(of: dragGestureActive, perform: { value in
|
||||
if value == true {
|
||||
if shouldStartAction() {
|
||||
feedbackGenerator.prepare()
|
||||
canStartAction = true
|
||||
}
|
||||
}
|
||||
})
|
||||
.overlay(alignment: .leading) {
|
||||
// We want the action icon to follow the view translation and gradually fade in
|
||||
label()
|
||||
.opacity(xOffset / 50)
|
||||
.animation(.interactiveSpring().speed(0.5), value: xOffset)
|
||||
.offset(x: -actionThreshold + min(xOffset, actionThreshold), y: 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Used to compute the horizontal translation amount.
|
||||
/// The more it's dragged the less it moves on a circular ease out curve
|
||||
private func circularEaseOut(_ value: Double) -> Double {
|
||||
sqrt((2 - value) * value)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func swipeRightAction(label: @escaping () -> some View,
|
||||
shouldStartAction: @escaping () -> Bool,
|
||||
action: @escaping () -> Void) -> some View {
|
||||
modifier(SwipeRightAction(label: label, shouldStartAction: shouldStartAction, action: action))
|
||||
}
|
||||
}
|
||||
|
||||
struct SwipeRightAction_Previews: PreviewProvider {
|
||||
static var previews: some View { Preview() }
|
||||
|
||||
struct Preview: View {
|
||||
@State private var isPresentingSheet = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
mockBubble("This is a message from somebody with a couple of lines of text.")
|
||||
.swipeRightAction {
|
||||
Image(systemName: "flame")
|
||||
} shouldStartAction: {
|
||||
true
|
||||
} action: {
|
||||
isPresentingSheet = true
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Work chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.sheet(isPresented: $isPresentingSheet) {
|
||||
Text("Action triggered!")
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
func mockBubble(_ body: String) -> some View {
|
||||
Text(body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.compound._bgBubbleOutgoing, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ struct TimelineItemMenuActions {
|
||||
self.actions = actions
|
||||
self.debugActions = debugActions
|
||||
}
|
||||
|
||||
var canReply: Bool {
|
||||
for action in actions {
|
||||
if case .reply = action {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum TimelineItemMenuAction: Identifiable, Hashable {
|
||||
|
||||
Reference in New Issue
Block a user