clox-view-switcher 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.js +2 -0
- package/README.md +35 -0
- package/android/build.gradle +42 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/cloxviewswitcher/CloxViewSwitcherModule.kt +21 -0
- package/android/src/main/java/expo/modules/cloxviewswitcher/CloxViewSwitcherView.kt +34 -0
- package/build/CloxViewSwitcher.types.d.ts +48 -0
- package/build/CloxViewSwitcher.types.d.ts.map +1 -0
- package/build/CloxViewSwitcher.types.js +2 -0
- package/build/CloxViewSwitcher.types.js.map +1 -0
- package/build/CloxViewSwitcherModule.d.ts +8 -0
- package/build/CloxViewSwitcherModule.d.ts.map +1 -0
- package/build/CloxViewSwitcherModule.js +4 -0
- package/build/CloxViewSwitcherModule.js.map +1 -0
- package/build/CloxViewSwitcherView.d.ts +4 -0
- package/build/CloxViewSwitcherView.d.ts.map +1 -0
- package/build/CloxViewSwitcherView.js +22 -0
- package/build/CloxViewSwitcherView.js.map +1 -0
- package/build/index.d.ts +5 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/example/App.tsx +89 -0
- package/example/android/app/build.gradle +182 -0
- package/example/android/app/debug.keystore +0 -0
- package/example/android/app/proguard-rules.pro +14 -0
- package/example/android/app/src/debug/AndroidManifest.xml +7 -0
- package/example/android/app/src/debugOptimized/AndroidManifest.xml +7 -0
- package/example/android/app/src/main/AndroidManifest.xml +31 -0
- package/example/android/app/src/main/java/expo/modules/cloxviewswitcher/example/MainActivity.kt +61 -0
- package/example/android/app/src/main/java/expo/modules/cloxviewswitcher/example/MainApplication.kt +56 -0
- package/example/android/app/src/main/res/drawable/ic_launcher_background.xml +6 -0
- package/example/android/app/src/main/res/drawable/rn_edit_text_material.xml +37 -0
- package/example/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png +0 -0
- package/example/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png +0 -0
- package/example/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png +0 -0
- package/example/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png +0 -0
- package/example/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png +0 -0
- package/example/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +5 -0
- package/example/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +5 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp +0 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp +0 -0
- package/example/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp +0 -0
- package/example/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp +0 -0
- package/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp +0 -0
- package/example/android/app/src/main/res/values/colors.xml +6 -0
- package/example/android/app/src/main/res/values/strings.xml +5 -0
- package/example/android/app/src/main/res/values/styles.xml +11 -0
- package/example/android/app/src/main/res/values-night/colors.xml +1 -0
- package/example/android/build.gradle +24 -0
- package/example/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/example/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/example/android/gradle.properties +65 -0
- package/example/android/gradlew +251 -0
- package/example/android/gradlew.bat +94 -0
- package/example/android/settings.gradle +39 -0
- package/example/app.json +31 -0
- package/example/assets/adaptive-icon.png +0 -0
- package/example/assets/favicon.png +0 -0
- package/example/assets/icon.png +0 -0
- package/example/assets/splash-icon.png +0 -0
- package/example/index.ts +8 -0
- package/example/ios/.xcode.env +11 -0
- package/example/ios/Podfile +60 -0
- package/example/ios/Podfile.lock +2211 -0
- package/example/ios/Podfile.properties.json +5 -0
- package/example/ios/cloxviewswitcherexample/AppDelegate.swift +70 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png +0 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/AppIcon.appiconset/Contents.json +14 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/Contents.json +6 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/SplashScreenBackground.colorset/Contents.json +20 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/SplashScreenLegacy.imageset/Contents.json +23 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/SplashScreenLegacy.imageset/image.png +0 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png +0 -0
- package/example/ios/cloxviewswitcherexample/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png +0 -0
- package/example/ios/cloxviewswitcherexample/Info.plist +82 -0
- package/example/ios/cloxviewswitcherexample/PrivacyInfo.xcprivacy +48 -0
- package/example/ios/cloxviewswitcherexample/SplashScreen.storyboard +48 -0
- package/example/ios/cloxviewswitcherexample/Supporting/Expo.plist +12 -0
- package/example/ios/cloxviewswitcherexample/cloxviewswitcherexample-Bridging-Header.h +3 -0
- package/example/ios/cloxviewswitcherexample/cloxviewswitcherexample.entitlements +5 -0
- package/example/ios/cloxviewswitcherexample.xcodeproj/project.pbxproj +552 -0
- package/example/ios/cloxviewswitcherexample.xcodeproj/xcshareddata/xcschemes/cloxviewswitcherexample.xcscheme +88 -0
- package/example/metro.config.js +34 -0
- package/example/package.json +34 -0
- package/example/tsconfig.json +11 -0
- package/example/yarn.lock +5771 -0
- package/expo-module.config.json +9 -0
- package/ios/AppSwitcherView.swift +283 -0
- package/ios/AppView.swift +98 -0
- package/ios/CloxViewSwitcher.podspec +30 -0
- package/ios/CloxViewSwitcherModule.swift +66 -0
- package/ios/CloxViewSwitcherView.swift +138 -0
- package/package.json +58 -0
- package/src/CloxViewSwitcher.types.ts +55 -0
- package/src/CloxViewSwitcherModule.ts +10 -0
- package/src/CloxViewSwitcherView.tsx +44 -0
- package/src/index.ts +11 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
//
|
|
2
|
+
// AppSwitcherView.swift
|
|
3
|
+
// CloxViewSwitcher
|
|
4
|
+
//
|
|
5
|
+
// Based on swiftui-app-switcher by Marcus Crafter
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import SwiftUI
|
|
9
|
+
|
|
10
|
+
/// Model representing an item in the switcher
|
|
11
|
+
struct SwitcherItem: Identifiable {
|
|
12
|
+
var id: String
|
|
13
|
+
var title: String
|
|
14
|
+
var imageURL: String
|
|
15
|
+
var iconURL: String
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Container view that handles gestures and renders the switcher
|
|
19
|
+
struct AppSwitcherContainerView: View {
|
|
20
|
+
let items: [SwitcherItem]
|
|
21
|
+
let springResponse: Double
|
|
22
|
+
let springDamping: Double
|
|
23
|
+
let scrollSpeed: Double
|
|
24
|
+
let cardBackgroundColor: String
|
|
25
|
+
let cardBorderRadius: Double
|
|
26
|
+
let titleFontColor: String
|
|
27
|
+
let titleFontSize: Double
|
|
28
|
+
let titleFontWeight: String
|
|
29
|
+
let onItemPress: (String) -> Void
|
|
30
|
+
let onCardChange: (String) -> Void
|
|
31
|
+
|
|
32
|
+
// Use @State instead of @GestureState to prevent automatic reset causing snap-back
|
|
33
|
+
@State private var activeDragX: CGFloat = 0
|
|
34
|
+
@State private var committedDragX: CGFloat = 0
|
|
35
|
+
@State private var isDragging: Bool = false
|
|
36
|
+
|
|
37
|
+
// Direct visual offset for rubber band (bypasses exponential curveX)
|
|
38
|
+
@State private var rubberBandOffset: CGFloat = 0
|
|
39
|
+
|
|
40
|
+
// Track the currently visible card to fire onCardChange
|
|
41
|
+
@State private var lastVisibleCardId: String? = nil
|
|
42
|
+
|
|
43
|
+
// Total drag position (clamped to bounds - rubber band is separate)
|
|
44
|
+
private var dragX: CGFloat {
|
|
45
|
+
activeDragX + committedDragX
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Bounds for drag
|
|
49
|
+
// maxDragX: how far right you can scroll (to see the last card)
|
|
50
|
+
// minDragX: how far left you can scroll (when first card is pushed left)
|
|
51
|
+
private var maxDragX: CGFloat { CGFloat(max(items.count - 1, 0)) * 50 + 20 }
|
|
52
|
+
private var minDragX: CGFloat { -50 } // Trigger rubber band early - don't let first card go too far left
|
|
53
|
+
|
|
54
|
+
// Rubber band settings - VISUAL pixels (not dragX), applied directly
|
|
55
|
+
private let rubberBandMultiplier: CGFloat = 0.4 // Resistance factor
|
|
56
|
+
private let maxVisualOvershoot: CGFloat = 40 // Max visual movement in pixels
|
|
57
|
+
|
|
58
|
+
// Constants for offset calculation (must match AppSwitcherView)
|
|
59
|
+
private let openingPanOffset: CGFloat = 200
|
|
60
|
+
private let openingAlignmentOffset: CGFloat = 80
|
|
61
|
+
private let appViewSpacing: CGFloat = 50
|
|
62
|
+
private let minStackingPerCard: CGFloat = 15
|
|
63
|
+
private let curveDampingFactor: CGFloat = 20
|
|
64
|
+
|
|
65
|
+
private func curveX(_ inputX: CGFloat) -> CGFloat {
|
|
66
|
+
pow(1.8, inputX / curveDampingFactor)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private func card0BaseOffset() -> CGFloat {
|
|
70
|
+
curveX(dragX + openingPanOffset - appViewSpacing) - openingAlignmentOffset
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func rawOffset(for index: Int) -> CGFloat {
|
|
74
|
+
curveX(dragX + openingPanOffset - CGFloat(index + 1) * appViewSpacing) - openingAlignmentOffset
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private func offset(for index: Int) -> CGFloat {
|
|
78
|
+
let baseOffset = rawOffset(for: index)
|
|
79
|
+
if index == 0 { return baseOffset }
|
|
80
|
+
let card0Pos = card0BaseOffset()
|
|
81
|
+
let stackingFloor = card0Pos - CGFloat(index) * minStackingPerCard
|
|
82
|
+
return min(baseOffset, stackingFloor)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Find the currently visible card (the one with title showing)
|
|
86
|
+
private var visibleCardId: String? {
|
|
87
|
+
for index in items.indices {
|
|
88
|
+
let currentOffset = offset(for: index)
|
|
89
|
+
if currentOffset > -50 && currentOffset < 180 {
|
|
90
|
+
return items[index].id
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return items.first?.id
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
var body: some View {
|
|
97
|
+
ZStack {
|
|
98
|
+
Color.clear
|
|
99
|
+
|
|
100
|
+
AppSwitcherView(
|
|
101
|
+
items: items,
|
|
102
|
+
dragX: dragX,
|
|
103
|
+
cardBackgroundColor: cardBackgroundColor,
|
|
104
|
+
cardBorderRadius: cardBorderRadius,
|
|
105
|
+
titleFontColor: titleFontColor,
|
|
106
|
+
titleFontSize: titleFontSize,
|
|
107
|
+
titleFontWeight: titleFontWeight,
|
|
108
|
+
onItemPress: { id in
|
|
109
|
+
if !isDragging {
|
|
110
|
+
onItemPress(id)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
.offset(x: rubberBandOffset) // Apply rubber band as direct visual offset
|
|
115
|
+
}
|
|
116
|
+
.contentShape(Rectangle())
|
|
117
|
+
.gesture(
|
|
118
|
+
DragGesture(minimumDistance: 5)
|
|
119
|
+
.onChanged { value in
|
|
120
|
+
isDragging = true
|
|
121
|
+
// Apply scroll speed multiplier to translation
|
|
122
|
+
let scaledTranslation = value.translation.width * CGFloat(scrollSpeed)
|
|
123
|
+
let proposedTotal = committedDragX + scaledTranslation
|
|
124
|
+
|
|
125
|
+
// Clamp dragX to bounds, calculate overshoot for rubber band
|
|
126
|
+
if proposedTotal > maxDragX {
|
|
127
|
+
// Past right bound - clamp dragX, apply visual rubber band
|
|
128
|
+
activeDragX = maxDragX - committedDragX
|
|
129
|
+
let overshoot = proposedTotal - maxDragX
|
|
130
|
+
rubberBandOffset = min(overshoot * rubberBandMultiplier, maxVisualOvershoot)
|
|
131
|
+
print("[DRAG] RIGHT RUBBER - overshoot: \(overshoot), visualOffset: \(rubberBandOffset)")
|
|
132
|
+
} else if proposedTotal < minDragX {
|
|
133
|
+
// Past left bound - clamp dragX, apply visual rubber band (negative)
|
|
134
|
+
activeDragX = minDragX - committedDragX
|
|
135
|
+
let overshoot = minDragX - proposedTotal
|
|
136
|
+
rubberBandOffset = -min(overshoot * rubberBandMultiplier, maxVisualOvershoot)
|
|
137
|
+
print("[DRAG] LEFT RUBBER - overshoot: \(overshoot), visualOffset: \(rubberBandOffset)")
|
|
138
|
+
} else {
|
|
139
|
+
// Within bounds - normal drag, no rubber band
|
|
140
|
+
activeDragX = scaledTranslation
|
|
141
|
+
rubberBandOffset = 0
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
print("[DRAG] translation: \(value.translation.width), dragX: \(dragX), rubberBandOffset: \(rubberBandOffset)")
|
|
145
|
+
}
|
|
146
|
+
.onEnded { value in
|
|
147
|
+
// Calculate momentum from velocity
|
|
148
|
+
let momentum = value.predictedEndTranslation.width - value.translation.width
|
|
149
|
+
|
|
150
|
+
// Current clamped position
|
|
151
|
+
let currentPosition = committedDragX + activeDragX
|
|
152
|
+
|
|
153
|
+
print("[END] currentPosition: \(currentPosition), rubberBandOffset: \(rubberBandOffset)")
|
|
154
|
+
print("[END] momentum: \(momentum)")
|
|
155
|
+
|
|
156
|
+
// Commit to current position
|
|
157
|
+
committedDragX = currentPosition
|
|
158
|
+
activeDragX = 0
|
|
159
|
+
|
|
160
|
+
// Animate rubber band back to zero (bounce back)
|
|
161
|
+
withAnimation(.spring(response: springResponse, dampingFraction: springDamping)) {
|
|
162
|
+
rubberBandOffset = 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// If we were within bounds, apply momentum
|
|
166
|
+
if rubberBandOffset == 0 {
|
|
167
|
+
let targetWithMomentum = currentPosition + momentum * 0.5
|
|
168
|
+
let finalPosition = max(minDragX, min(maxDragX, targetWithMomentum))
|
|
169
|
+
|
|
170
|
+
print("[END] targetWithMomentum: \(targetWithMomentum), finalPosition: \(finalPosition)")
|
|
171
|
+
|
|
172
|
+
withAnimation(.spring(response: springResponse, dampingFraction: springDamping)) {
|
|
173
|
+
committedDragX = finalPosition
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
178
|
+
isDragging = false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
.onChange(of: visibleCardId) { newValue in
|
|
183
|
+
if let id = newValue, id != lastVisibleCardId {
|
|
184
|
+
lastVisibleCardId = id
|
|
185
|
+
onCardChange(id)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
.onAppear {
|
|
189
|
+
// Fire initial card change on appear
|
|
190
|
+
if let id = visibleCardId {
|
|
191
|
+
lastVisibleCardId = id
|
|
192
|
+
onCardChange(id)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/// The app switcher view showing overlapping app cards
|
|
199
|
+
struct AppSwitcherView: View {
|
|
200
|
+
let items: [SwitcherItem]
|
|
201
|
+
var dragX: CGFloat
|
|
202
|
+
let cardBackgroundColor: String
|
|
203
|
+
let cardBorderRadius: Double
|
|
204
|
+
let titleFontColor: String
|
|
205
|
+
let titleFontSize: Double
|
|
206
|
+
let titleFontWeight: String
|
|
207
|
+
let onItemPress: (String) -> Void
|
|
208
|
+
|
|
209
|
+
var body: some View {
|
|
210
|
+
ZStack {
|
|
211
|
+
ForEach(items.indices.reversed(), id: \.self) { index in
|
|
212
|
+
AppCardView(
|
|
213
|
+
item: items[index],
|
|
214
|
+
titleOpacity: titleOpacity(for: index),
|
|
215
|
+
backgroundColor: cardBackgroundColor,
|
|
216
|
+
borderRadius: cardBorderRadius,
|
|
217
|
+
titleFontColor: titleFontColor,
|
|
218
|
+
titleFontSize: titleFontSize,
|
|
219
|
+
titleFontWeight: titleFontWeight,
|
|
220
|
+
onPress: { onItemPress(items[index].id) }
|
|
221
|
+
)
|
|
222
|
+
.offset(x: offset(for: index))
|
|
223
|
+
.scaleEffect(scale(for: index))
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private let openingPanOffset: CGFloat = 200
|
|
229
|
+
private let openingAlignmentOffset: CGFloat = 80
|
|
230
|
+
private let appViewSpacing: CGFloat = 50
|
|
231
|
+
private let minStackingPerCard: CGFloat = 15 // Minimum pixels between stacked cards
|
|
232
|
+
|
|
233
|
+
/// Calculate offset for card 0 (used as reference for stacking)
|
|
234
|
+
private func card0BaseOffset() -> CGFloat {
|
|
235
|
+
curveX(dragX + openingPanOffset - appViewSpacing) - openingAlignmentOffset
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Calculate raw curved offset without stacking adjustment
|
|
239
|
+
private func rawOffset(for index: Int) -> CGFloat {
|
|
240
|
+
curveX(dragX + openingPanOffset - CGFloat(index + 1) * appViewSpacing) - openingAlignmentOffset
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private func offset(for index: Int) -> CGFloat {
|
|
244
|
+
let baseOffset = rawOffset(for: index)
|
|
245
|
+
|
|
246
|
+
// Card 0 uses its natural offset
|
|
247
|
+
if index == 0 {
|
|
248
|
+
return baseOffset
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// For other cards: ensure minimum stacking relative to card 0
|
|
252
|
+
// This prevents all cards from converging to the same position when dragged left
|
|
253
|
+
let card0Pos = card0BaseOffset()
|
|
254
|
+
let stackingFloor = card0Pos - CGFloat(index) * minStackingPerCard
|
|
255
|
+
|
|
256
|
+
// Use the more-left position (min) to ensure stacking is maintained
|
|
257
|
+
return min(baseOffset, stackingFloor)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private let scaleBandingFactor: CGFloat = 2000
|
|
261
|
+
|
|
262
|
+
private func scale(for index: Int) -> CGFloat {
|
|
263
|
+
let cardOffset = offset(for: index)
|
|
264
|
+
if cardOffset > 0 {
|
|
265
|
+
return 1
|
|
266
|
+
}
|
|
267
|
+
return max(0.94, (1 - abs(cardOffset) / scaleBandingFactor))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private func titleOpacity(for index: Int) -> Double {
|
|
271
|
+
let currentOffset = offset(for: index)
|
|
272
|
+
if currentOffset > -50 && currentOffset < 180 {
|
|
273
|
+
return 1
|
|
274
|
+
}
|
|
275
|
+
return 0
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private let curveDampingFactor: CGFloat = 20
|
|
279
|
+
|
|
280
|
+
private func curveX(_ inputX: CGFloat) -> CGFloat {
|
|
281
|
+
pow(1.8, inputX / curveDampingFactor)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
//
|
|
2
|
+
// AppView.swift
|
|
3
|
+
// CloxViewSwitcher
|
|
4
|
+
//
|
|
5
|
+
// Based on swiftui-app-switcher by Marcus Crafter
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import SwiftUI
|
|
9
|
+
|
|
10
|
+
/// View representing an app card in the switcher
|
|
11
|
+
struct AppCardView: View {
|
|
12
|
+
let item: SwitcherItem
|
|
13
|
+
var titleOpacity: Double
|
|
14
|
+
var backgroundColor: String
|
|
15
|
+
var borderRadius: Double
|
|
16
|
+
var titleFontColor: String
|
|
17
|
+
var titleFontSize: Double
|
|
18
|
+
var titleFontWeight: String
|
|
19
|
+
let onPress: () -> Void
|
|
20
|
+
|
|
21
|
+
/// Convert hex string to Color
|
|
22
|
+
private func colorFromHex(_ hex: String) -> Color {
|
|
23
|
+
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
24
|
+
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
|
25
|
+
|
|
26
|
+
var rgb: UInt64 = 0
|
|
27
|
+
Scanner(string: hexSanitized).scanHexInt64(&rgb)
|
|
28
|
+
|
|
29
|
+
let red = Double((rgb & 0xFF0000) >> 16) / 255.0
|
|
30
|
+
let green = Double((rgb & 0x00FF00) >> 8) / 255.0
|
|
31
|
+
let blue = Double(rgb & 0x0000FF) / 255.0
|
|
32
|
+
|
|
33
|
+
return Color(red: red, green: green, blue: blue)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// Convert weight string to Font.Weight
|
|
37
|
+
private func fontWeight(from string: String) -> Font.Weight {
|
|
38
|
+
switch string.lowercased() {
|
|
39
|
+
case "regular": return .regular
|
|
40
|
+
case "medium": return .medium
|
|
41
|
+
case "semibold": return .semibold
|
|
42
|
+
case "bold": return .bold
|
|
43
|
+
case "heavy": return .heavy
|
|
44
|
+
default: return .bold
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
var body: some View {
|
|
49
|
+
VStack(alignment: .leading) {
|
|
50
|
+
// Header with icon and title
|
|
51
|
+
HStack {
|
|
52
|
+
// App icon
|
|
53
|
+
AsyncImage(url: URL(string: item.iconURL)) { phase in
|
|
54
|
+
switch phase {
|
|
55
|
+
case .success(let image):
|
|
56
|
+
image
|
|
57
|
+
.resizable()
|
|
58
|
+
.aspectRatio(contentMode: .fill)
|
|
59
|
+
default:
|
|
60
|
+
RoundedRectangle(cornerRadius: 6)
|
|
61
|
+
.fill(colorFromHex(backgroundColor))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
.frame(width: 30, height: 30)
|
|
65
|
+
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
66
|
+
|
|
67
|
+
Text(item.title)
|
|
68
|
+
.font(.system(size: CGFloat(titleFontSize), weight: fontWeight(from: titleFontWeight)))
|
|
69
|
+
.foregroundColor(colorFromHex(titleFontColor))
|
|
70
|
+
.opacity(titleOpacity)
|
|
71
|
+
}
|
|
72
|
+
.offset(x: 15)
|
|
73
|
+
|
|
74
|
+
// Main app screenshot
|
|
75
|
+
ZStack {
|
|
76
|
+
AsyncImage(url: URL(string: item.imageURL)) { phase in
|
|
77
|
+
switch phase {
|
|
78
|
+
case .success(let image):
|
|
79
|
+
image
|
|
80
|
+
.resizable()
|
|
81
|
+
.aspectRatio(contentMode: .fill)
|
|
82
|
+
default:
|
|
83
|
+
Rectangle()
|
|
84
|
+
.fill(colorFromHex(backgroundColor))
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
.frame(width: 260, height: 540)
|
|
89
|
+
.clipShape(RoundedRectangle(cornerRadius: CGFloat(borderRadius)))
|
|
90
|
+
.shadow(radius: 3)
|
|
91
|
+
}
|
|
92
|
+
.frame(width: 260, height: 580)
|
|
93
|
+
.contentShape(Rectangle())
|
|
94
|
+
.onTapGesture {
|
|
95
|
+
onPress()
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
|
|
4
|
+
|
|
5
|
+
Pod::Spec.new do |s|
|
|
6
|
+
s.name = 'CloxViewSwitcher'
|
|
7
|
+
s.version = package['version']
|
|
8
|
+
s.summary = package['description']
|
|
9
|
+
s.description = package['description']
|
|
10
|
+
s.license = package['license']
|
|
11
|
+
s.author = package['author']
|
|
12
|
+
s.homepage = package['homepage']
|
|
13
|
+
s.platforms = {
|
|
14
|
+
:ios => '15.1',
|
|
15
|
+
:tvos => '15.1'
|
|
16
|
+
}
|
|
17
|
+
s.swift_version = '5.9'
|
|
18
|
+
s.source = { git: 'https://github.com/prolific-life/clox-view-switcher' }
|
|
19
|
+
s.static_framework = true
|
|
20
|
+
|
|
21
|
+
s.dependency 'ExpoModulesCore'
|
|
22
|
+
|
|
23
|
+
# Swift/Objective-C compatibility
|
|
24
|
+
s.pod_target_xcconfig = {
|
|
25
|
+
'DEFINES_MODULE' => 'YES',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
|
|
29
|
+
s.exclude_files = "build/**/*"
|
|
30
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
|
|
3
|
+
public class CloxViewSwitcherModule: Module {
|
|
4
|
+
public func definition() -> ModuleDefinition {
|
|
5
|
+
Name("CloxViewSwitcher")
|
|
6
|
+
|
|
7
|
+
// Defines event names that the module can send to JavaScript.
|
|
8
|
+
Events("onChange", "onItemPress", "onCardChange")
|
|
9
|
+
|
|
10
|
+
// Defines a JavaScript synchronous function
|
|
11
|
+
Function("hello") {
|
|
12
|
+
return "Hello World! 👋"
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Enables the module to be used as a native view.
|
|
16
|
+
View(CloxViewSwitcherView.self) {
|
|
17
|
+
// Items prop - array of app items
|
|
18
|
+
Prop("items") { (view: CloxViewSwitcherView, items: [[String: String]]) in
|
|
19
|
+
view.setItems(items)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Spring animation response (duration)
|
|
23
|
+
Prop("springResponse") { (view: CloxViewSwitcherView, value: Double) in
|
|
24
|
+
view.setSpringResponse(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Spring animation damping fraction
|
|
28
|
+
Prop("springDamping") { (view: CloxViewSwitcherView, value: Double) in
|
|
29
|
+
view.setSpringDamping(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Scroll speed multiplier
|
|
33
|
+
Prop("scrollSpeed") { (view: CloxViewSwitcherView, value: Double) in
|
|
34
|
+
view.setScrollSpeed(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Card background color (placeholder before images load)
|
|
38
|
+
Prop("cardBackgroundColor") { (view: CloxViewSwitcherView, value: String) in
|
|
39
|
+
view.setCardBackgroundColor(value)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Card border radius
|
|
43
|
+
Prop("cardBorderRadius") { (view: CloxViewSwitcherView, value: Double) in
|
|
44
|
+
view.setCardBorderRadius(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Title font color
|
|
48
|
+
Prop("titleFontColor") { (view: CloxViewSwitcherView, value: String) in
|
|
49
|
+
view.setTitleFontColor(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Title font size
|
|
53
|
+
Prop("titleFontSize") { (view: CloxViewSwitcherView, value: Double) in
|
|
54
|
+
view.setTitleFontSize(value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Title font weight
|
|
58
|
+
Prop("titleFontWeight") { (view: CloxViewSwitcherView, value: String) in
|
|
59
|
+
view.setTitleFontWeight(value)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Events
|
|
63
|
+
Events("onItemPress", "onCardChange")
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//
|
|
2
|
+
// CloxViewSwitcherView.swift
|
|
3
|
+
// CloxViewSwitcher
|
|
4
|
+
//
|
|
5
|
+
// Native view that wraps the SwiftUI app switcher
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import ExpoModulesCore
|
|
9
|
+
import UIKit
|
|
10
|
+
import SwiftUI
|
|
11
|
+
|
|
12
|
+
class CloxViewSwitcherView: ExpoView {
|
|
13
|
+
|
|
14
|
+
private var hostingController: UIHostingController<AnyView>?
|
|
15
|
+
private var items: [SwitcherItem] = []
|
|
16
|
+
private var springResponse: Double = 0.5
|
|
17
|
+
private var springDamping: Double = 0.85
|
|
18
|
+
private var scrollSpeed: Double = 1.0
|
|
19
|
+
private var cardBackgroundColor: String = "#FFFFFF"
|
|
20
|
+
private var cardBorderRadius: Double = 20.0
|
|
21
|
+
private var titleFontColor: String = "#FFFFFF"
|
|
22
|
+
private var titleFontSize: Double = 14.0
|
|
23
|
+
private var titleFontWeight: String = "bold"
|
|
24
|
+
|
|
25
|
+
// Event emitters
|
|
26
|
+
let onItemPress = EventDispatcher()
|
|
27
|
+
let onCardChange = EventDispatcher()
|
|
28
|
+
|
|
29
|
+
required init(appContext: AppContext? = nil) {
|
|
30
|
+
super.init(appContext: appContext)
|
|
31
|
+
setupView()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private func setupView() {
|
|
35
|
+
backgroundColor = .clear
|
|
36
|
+
updateSwiftUIView()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func setItems(_ itemsData: [[String: String]]) {
|
|
40
|
+
self.items = itemsData.compactMap { dict in
|
|
41
|
+
guard let id = dict["id"],
|
|
42
|
+
let title = dict["title"],
|
|
43
|
+
let image = dict["image"],
|
|
44
|
+
let icon = dict["icon"] else {
|
|
45
|
+
return nil
|
|
46
|
+
}
|
|
47
|
+
return SwitcherItem(id: id, title: title, imageURL: image, iconURL: icon)
|
|
48
|
+
}
|
|
49
|
+
updateSwiftUIView()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func setSpringResponse(_ value: Double) {
|
|
53
|
+
self.springResponse = value
|
|
54
|
+
updateSwiftUIView()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func setSpringDamping(_ value: Double) {
|
|
58
|
+
self.springDamping = value
|
|
59
|
+
updateSwiftUIView()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func setScrollSpeed(_ value: Double) {
|
|
63
|
+
self.scrollSpeed = value
|
|
64
|
+
updateSwiftUIView()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func setCardBackgroundColor(_ value: String) {
|
|
68
|
+
self.cardBackgroundColor = value
|
|
69
|
+
updateSwiftUIView()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func setCardBorderRadius(_ value: Double) {
|
|
73
|
+
self.cardBorderRadius = value
|
|
74
|
+
updateSwiftUIView()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
func setTitleFontColor(_ value: String) {
|
|
78
|
+
self.titleFontColor = value
|
|
79
|
+
updateSwiftUIView()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
func setTitleFontSize(_ value: Double) {
|
|
83
|
+
self.titleFontSize = value
|
|
84
|
+
updateSwiftUIView()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func setTitleFontWeight(_ value: String) {
|
|
88
|
+
self.titleFontWeight = value
|
|
89
|
+
updateSwiftUIView()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private func updateSwiftUIView() {
|
|
93
|
+
// Remove existing hosting controller view
|
|
94
|
+
hostingController?.view.removeFromSuperview()
|
|
95
|
+
|
|
96
|
+
// Create the SwiftUI view with items
|
|
97
|
+
let switcherView = AppSwitcherContainerView(
|
|
98
|
+
items: items,
|
|
99
|
+
springResponse: springResponse,
|
|
100
|
+
springDamping: springDamping,
|
|
101
|
+
scrollSpeed: scrollSpeed,
|
|
102
|
+
cardBackgroundColor: cardBackgroundColor,
|
|
103
|
+
cardBorderRadius: cardBorderRadius,
|
|
104
|
+
titleFontColor: titleFontColor,
|
|
105
|
+
titleFontSize: titleFontSize,
|
|
106
|
+
titleFontWeight: titleFontWeight,
|
|
107
|
+
onItemPress: { [weak self] id in
|
|
108
|
+
self?.onItemPress(["id": id])
|
|
109
|
+
},
|
|
110
|
+
onCardChange: { [weak self] id in
|
|
111
|
+
self?.onCardChange(["id": id])
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
// Wrap it in a hosting controller
|
|
116
|
+
let hostingController = UIHostingController(rootView: AnyView(switcherView))
|
|
117
|
+
hostingController.view.backgroundColor = .clear
|
|
118
|
+
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
119
|
+
|
|
120
|
+
self.hostingController = hostingController
|
|
121
|
+
|
|
122
|
+
// Add the hosting controller's view
|
|
123
|
+
addSubview(hostingController.view)
|
|
124
|
+
|
|
125
|
+
// Constrain to fill the parent view
|
|
126
|
+
NSLayoutConstraint.activate([
|
|
127
|
+
hostingController.view.topAnchor.constraint(equalTo: topAnchor),
|
|
128
|
+
hostingController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
129
|
+
hostingController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
130
|
+
hostingController.view.bottomAnchor.constraint(equalTo: bottomAnchor)
|
|
131
|
+
])
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
override func layoutSubviews() {
|
|
135
|
+
super.layoutSubviews()
|
|
136
|
+
hostingController?.view.frame = bounds
|
|
137
|
+
}
|
|
138
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clox-view-switcher",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A native Expo module for view switching animations",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "expo-module build",
|
|
9
|
+
"clean": "expo-module clean",
|
|
10
|
+
"lint": "expo-module lint",
|
|
11
|
+
"test": "expo-module test",
|
|
12
|
+
"prepare": "expo-module prepare",
|
|
13
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
14
|
+
"expo-module": "expo-module",
|
|
15
|
+
"open:ios": "xed example/ios",
|
|
16
|
+
"open:android": "open -a \"Android Studio\" example/android",
|
|
17
|
+
"android": "expo run:android",
|
|
18
|
+
"ios": "expo run:ios"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react-native",
|
|
22
|
+
"expo",
|
|
23
|
+
"clox-view-switcher",
|
|
24
|
+
"CloxViewSwitcher",
|
|
25
|
+
"view-switcher",
|
|
26
|
+
"animation"
|
|
27
|
+
],
|
|
28
|
+
"repository": "https://github.com/prolific-life/clox-view-switcher",
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/prolific-life/clox-view-switcher/issues"
|
|
31
|
+
},
|
|
32
|
+
"author": "Yash Saxena <yash.saxena1217@gmail.com> (https://github.com/prolific-life/clox-view-switcher)",
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"homepage": "https://github.com/prolific-life/clox-view-switcher#readme",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18.14.0 || >=23.0.0"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"expo": "~54.0.12",
|
|
40
|
+
"react": "19.1.0",
|
|
41
|
+
"react-native": "0.81.4"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/react": "~19.1.0",
|
|
45
|
+
"@react-native-community/cli": "latest",
|
|
46
|
+
"expo-module-scripts": "^5.0.7"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"expo": "*",
|
|
50
|
+
"react": "*",
|
|
51
|
+
"react-native": "*"
|
|
52
|
+
},
|
|
53
|
+
"overrides": {
|
|
54
|
+
"glob": "^9.3.5",
|
|
55
|
+
"rimraf": "^5.0.0",
|
|
56
|
+
"pretty-format": "^29.7.0"
|
|
57
|
+
}
|
|
58
|
+
}
|