mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:25:20 +00:00
Fix send button tap responsiveness and sidebar drag jitter (#783)
Restructured ContentView layout to prevent sidebar from covering input box and removed gesture conflicts that caused jitter during slow drags. Changes: - Moved sidebar overlay to only cover messages area, not input box - Input box now always accessible below sidebar (not covered by overlay) - Removed blocking drag gesture from mainChatView - Changed sidebar gesture from simultaneousGesture to gesture for priority - Removed animation-disabling transactions that amplified touch noise - Removed 2pt threshold checks that caused visible jumps Result: Send button taps immediately, sidebar slides smoothly without jitter. Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
+110
-174
@@ -83,118 +83,131 @@ struct ContentView: View {
|
|||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geometry in
|
VStack(spacing: 0) {
|
||||||
ZStack {
|
mainHeaderView
|
||||||
// Base layer - Main public chat (always visible)
|
.onAppear { viewModel.currentColorScheme = colorScheme }
|
||||||
mainChatView
|
.onChange(of: colorScheme) { newValue in
|
||||||
.onAppear { viewModel.currentColorScheme = colorScheme }
|
viewModel.currentColorScheme = newValue
|
||||||
.onChange(of: colorScheme) { newValue in
|
|
||||||
viewModel.currentColorScheme = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Private chat slide-over
|
|
||||||
if viewModel.selectedPrivateChatPeer != nil {
|
|
||||||
privateChatView
|
|
||||||
.frame(width: geometry.size.width)
|
|
||||||
.background(backgroundColor)
|
|
||||||
.transition(.asymmetric(
|
|
||||||
insertion: .move(edge: .trailing),
|
|
||||||
removal: .move(edge: .trailing)
|
|
||||||
))
|
|
||||||
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
|
|
||||||
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
|
|
||||||
.gesture(
|
|
||||||
DragGesture()
|
|
||||||
.onChanged { value in
|
|
||||||
if value.translation.width > 0 && !value.translation.width.isNaN {
|
|
||||||
let maxWidth = max(0, geometry.size.width)
|
|
||||||
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
showPrivateChat = false
|
|
||||||
backSwipeOffset = 0
|
|
||||||
viewModel.endPrivateChat()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
|
||||||
backSwipeOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
Divider()
|
||||||
if !showSidebar {
|
|
||||||
HStack {
|
// Messages area with overlays
|
||||||
Spacer()
|
GeometryReader { geometry in
|
||||||
Color.clear
|
ZStack {
|
||||||
.frame(width: 20)
|
// Base layer - public messages
|
||||||
.contentShape(Rectangle())
|
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
||||||
|
.background(backgroundColor)
|
||||||
|
|
||||||
|
// Private chat slide-over (full screen with own header/input)
|
||||||
|
if viewModel.selectedPrivateChatPeer != nil {
|
||||||
|
privateChatView
|
||||||
|
.frame(width: geometry.size.width)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .move(edge: .trailing),
|
||||||
|
removal: .move(edge: .trailing)
|
||||||
|
))
|
||||||
|
.offset(x: showPrivateChat ? -1 : max(0, geometry.size.width))
|
||||||
|
.offset(x: backSwipeOffset.isNaN ? 0 : backSwipeOffset)
|
||||||
.gesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 5)
|
DragGesture()
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
if value.translation.width > 0 && !value.translation.width.isNaN {
|
||||||
if translationWidth < 0 {
|
let maxWidth = max(0, geometry.size.width)
|
||||||
let newOffset = max(translationWidth, -300)
|
backSwipeOffset = min(value.translation.width, maxWidth.isNaN ? 0 : maxWidth)
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
let translation = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
|
||||||
if translationWidth < -50 || velocity < -300 {
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
showSidebar = true
|
showPrivateChat = false
|
||||||
sidebarDragOffset = 0
|
backSwipeOffset = 0
|
||||||
} else {
|
viewModel.endPrivateChat()
|
||||||
sidebarDragOffset = 0
|
}
|
||||||
|
} else {
|
||||||
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
|
||||||
|
backSwipeOffset = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.allowsHitTesting(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sidebar overlay
|
// Right edge swipe zone for easier sidebar opening (iOS-native behavior)
|
||||||
HStack(spacing: 0) {
|
if !showSidebar && viewModel.selectedPrivateChatPeer == nil {
|
||||||
// Tap to dismiss area
|
HStack {
|
||||||
Color.clear
|
Spacer()
|
||||||
.contentShape(Rectangle())
|
Color.clear
|
||||||
.onTapGesture {
|
.frame(width: 20)
|
||||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
.contentShape(Rectangle())
|
||||||
showSidebar = false
|
.gesture(
|
||||||
sidebarDragOffset = 0
|
DragGesture(minimumDistance: 5)
|
||||||
}
|
.onChanged { value in
|
||||||
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
|
if translationWidth < 0 {
|
||||||
|
let newOffset = max(translationWidth, -300)
|
||||||
|
sidebarDragOffset = newOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
|
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
||||||
|
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
if translationWidth < -50 || velocity < -300 {
|
||||||
|
showSidebar = true
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
} else {
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.allowsHitTesting(true)
|
||||||
// Always render sidebar to avoid layout recalculation during drag
|
}
|
||||||
sidebarView
|
|
||||||
#if os(macOS)
|
// Sidebar overlay (only over messages area, not input)
|
||||||
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
if viewModel.selectedPrivateChatPeer == nil {
|
||||||
#else
|
HStack(spacing: 0) {
|
||||||
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
// Tap to dismiss area
|
||||||
#endif
|
Color.clear
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
showSidebar = false
|
||||||
|
sidebarDragOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always render sidebar to avoid layout recalculation during drag
|
||||||
|
sidebarView
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(width: min(300, max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.4))
|
||||||
|
#else
|
||||||
|
.frame(width: max(0, geometry.size.width.isNaN ? 300 : geometry.size.width) * 0.7)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.offset(x: {
|
||||||
|
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
||||||
|
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
||||||
|
return showSidebar ? dragOffset : width + dragOffset
|
||||||
|
}())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.offset(x: {
|
}
|
||||||
let dragOffset = sidebarDragOffset.isNaN ? 0 : sidebarDragOffset
|
|
||||||
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
|
Divider()
|
||||||
return showSidebar ? dragOffset : width + dragOffset
|
|
||||||
}())
|
// Input always accessible below sidebar (only in public chat)
|
||||||
|
if viewModel.selectedPrivateChatPeer == nil {
|
||||||
|
inputView
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
.foregroundColor(textColor)
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.frame(minWidth: 600, minHeight: 400)
|
.frame(minWidth: 600, minHeight: 400)
|
||||||
#endif
|
#endif
|
||||||
@@ -951,7 +964,7 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.simultaneousGesture(
|
.gesture(
|
||||||
DragGesture(minimumDistance: 10)
|
DragGesture(minimumDistance: 10)
|
||||||
.onChanged { value in
|
.onChanged { value in
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
||||||
@@ -962,13 +975,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
if translationWidth > 0 {
|
if translationWidth > 0 {
|
||||||
let newOffset = min(translationWidth, 300)
|
let newOffset = min(translationWidth, 300)
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
sidebarDragOffset = newOffset
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onEnded { value in
|
.onEnded { value in
|
||||||
@@ -998,78 +1005,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - View Components
|
// MARK: - View Components
|
||||||
|
|
||||||
private var mainChatView: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
mainHeaderView
|
|
||||||
Divider()
|
|
||||||
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
|
|
||||||
Divider()
|
|
||||||
inputView
|
|
||||||
}
|
|
||||||
.background(backgroundColor)
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.simultaneousGesture(
|
|
||||||
DragGesture(minimumDistance: 10)
|
|
||||||
.onChanged { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
|
||||||
|
|
||||||
// Only handle if drag is predominantly horizontal (prevents interfering with scroll)
|
|
||||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else { return }
|
|
||||||
|
|
||||||
if !showSidebar && translationWidth < 0 {
|
|
||||||
let newOffset = max(translationWidth, -300)
|
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if showSidebar && translationWidth > 0 {
|
|
||||||
let newOffset = min(-300 + translationWidth, 0)
|
|
||||||
if abs(newOffset - sidebarDragOffset) > 2 {
|
|
||||||
var transaction = Transaction()
|
|
||||||
transaction.disablesAnimations = true
|
|
||||||
withTransaction(transaction) {
|
|
||||||
sidebarDragOffset = newOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onEnded { value in
|
|
||||||
let translationWidth = value.translation.width.isNaN ? 0 : value.translation.width
|
|
||||||
let translationHeight = value.translation.height.isNaN ? 0 : value.translation.height
|
|
||||||
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
|
|
||||||
|
|
||||||
// Only handle if drag is predominantly horizontal
|
|
||||||
guard abs(translationWidth) > abs(translationHeight) * 1.5 else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
|
||||||
if !showSidebar {
|
|
||||||
if translationWidth < -100 || (translationWidth < -50 && velocity < -500) {
|
|
||||||
showSidebar = true
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if translationWidth > 100 || (translationWidth > 50 && velocity > 500) {
|
|
||||||
showSidebar = false
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
} else {
|
|
||||||
sidebarDragOffset = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var privateChatView: some View {
|
private var privateChatView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// Vertical separator bar
|
// Vertical separator bar
|
||||||
|
|||||||
Reference in New Issue
Block a user