Merge branch 'main' into hailey/migrate-expo-file-system

# Conflicts:
#	__tests__/lib/images.test.ts
#	package.json
#	src/lib/api/index.ts
#	src/lib/media/manip.ts
#	yarn.lock
This commit is contained in:
Hailey
2024-04-27 23:39:44 -07:00
406 changed files with 44867 additions and 32184 deletions
@@ -0,0 +1,98 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'expo.modules.blueskygifview'
version = '0.5.0'
buildscript {
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
// Simple helper that allows the root project to override versions declared by this library.
ext.safeExtGet = { prop, fallback ->
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// Ensures backward compatibility
ext.getKotlinVersion = {
if (ext.has("kotlinVersion")) {
ext.kotlinVersion()
} else {
ext.safeExtGet("kotlinVersion", "1.8.10")
}
}
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
}
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.majorVersion
}
}
namespace "expo.modules.blueskygifview"
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
versionCode 1
versionName "0.5.0"
}
lintOptions {
abortOnError false
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
def GLIDE_VERSION = "4.13.2"
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
// Keep glide version up to date with expo-image so that we don't have duplicate deps
implementation 'com.github.bumptech.glide:glide:4.13.2'
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,37 @@
package expo.modules.blueskygifview
import android.content.Context
import android.graphics.Canvas
import android.graphics.drawable.Animatable
import androidx.appcompat.widget.AppCompatImageView
class AppCompatImageViewExtended(context: Context, private val parent: GifView): AppCompatImageView(context) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (this.drawable is Animatable) {
if (!parent.isLoaded) {
parent.isLoaded = true
parent.firePlayerStateChange()
}
if (!parent.isPlaying) {
this.pause()
}
}
}
fun pause() {
val drawable = this.drawable
if (drawable is Animatable) {
drawable.stop()
}
}
fun play() {
val drawable = this.drawable
if (drawable is Animatable) {
drawable.start()
}
}
}
@@ -0,0 +1,54 @@
package expo.modules.blueskygifview
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ExpoBlueskyGifViewModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoBlueskyGifView")
AsyncFunction("prefetchAsync") { sources: List<String> ->
val activity = appContext.currentActivity ?: return@AsyncFunction
val glide = Glide.with(activity)
sources.forEach { source ->
glide
.download(source)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.submit()
}
}
View(GifView::class) {
Events(
"onPlayerStateChange"
)
Prop("source") { view: GifView, source: String ->
view.source = source
}
Prop("placeholderSource") { view: GifView, source: String ->
view.placeholderSource = source
}
Prop("autoplay") { view: GifView, autoplay: Boolean ->
view.autoplay = autoplay
}
AsyncFunction("playAsync") { view: GifView ->
view.play()
}
AsyncFunction("pauseAsync") { view: GifView ->
view.pause()
}
AsyncFunction("toggleAsync") { view: GifView ->
view.toggle()
}
}
}
}
@@ -0,0 +1,180 @@
package expo.modules.blueskygifview
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.graphics.drawable.Drawable
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.RequestListener
import com.bumptech.glide.request.target.Target
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
class GifView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
// Events
private val onPlayerStateChange by EventDispatcher()
// Glide
private val activity = appContext.currentActivity ?: throw Exceptions.MissingActivity()
private val glide = Glide.with(activity)
val imageView = AppCompatImageViewExtended(context, this)
var isPlaying = true
var isLoaded = false
// Requests
private var placeholderRequest: Target<Drawable>? = null
private var webpRequest: Target<Drawable>? = null
// Props
var placeholderSource: String? = null
var source: String? = null
var autoplay: Boolean = true
set(value) {
field = value
if (value) {
this.play()
} else {
this.pause()
}
}
//<editor-fold desc="Lifecycle">
init {
this.setBackgroundColor(Color.TRANSPARENT)
this.imageView.setBackgroundColor(Color.TRANSPARENT)
this.imageView.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
this.addView(this.imageView)
}
override fun onAttachedToWindow() {
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.load()
} else if (this.isPlaying) {
this.imageView.play()
}
super.onAttachedToWindow()
}
override fun onDetachedFromWindow() {
this.imageView.pause()
super.onDetachedFromWindow()
}
//</editor-fold>
//<editor-fold desc="Loading">
private fun load() {
if (placeholderSource == null || source == null) {
return
}
this.webpRequest = glide.load(source)
.diskCacheStrategy(DiskCacheStrategy.DATA)
.skipMemoryCache(false)
.listener(object: RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean
): Boolean {
if (placeholderRequest != null) {
glide.clear(placeholderRequest)
}
return false
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
return true
}
})
.into(this.imageView)
if (this.imageView.drawable == null || this.imageView.drawable !is Animatable) {
this.placeholderRequest = glide.load(placeholderSource)
.diskCacheStrategy(DiskCacheStrategy.DATA)
// Let's not bloat the memory cache with placeholders
.skipMemoryCache(true)
.listener(object: RequestListener<Drawable> {
override fun onResourceReady(
resource: Drawable?,
model: Any?,
target: Target<Drawable>?,
dataSource: com.bumptech.glide.load.DataSource?,
isFirstResource: Boolean
): Boolean {
// Incase this request finishes after the webp, let's just not set
// the drawable. This shouldn't happen because the request should get cancelled
if (imageView.drawable == null) {
imageView.setImageDrawable(resource)
}
return true
}
override fun onLoadFailed(
e: GlideException?,
model: Any?,
target: Target<Drawable>?,
isFirstResource: Boolean
): Boolean {
return true
}
})
.submit()
}
}
//</editor-fold>
//<editor-fold desc="Controls">
fun play() {
this.imageView.play()
this.isPlaying = true
this.firePlayerStateChange()
}
fun pause() {
this.imageView.pause()
this.isPlaying = false
this.firePlayerStateChange()
}
fun toggle() {
if (this.isPlaying) {
this.pause()
} else {
this.play()
}
}
//</editor-fold>
//<editor-fold desc="Util">
fun firePlayerStateChange() {
onPlayerStateChange(mapOf(
"isPlaying" to this.isPlaying,
"isLoaded" to this.isLoaded,
))
}
//</editor-fold>
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android", "web"],
"ios": {
"modules": ["ExpoBlueskyGifViewModule"]
},
"android": {
"modules": ["expo.modules.blueskygifview.ExpoBlueskyGifViewModule"]
}
}
+1
View File
@@ -0,0 +1 @@
export {GifView} from './src/GifView'
@@ -0,0 +1,23 @@
Pod::Spec.new do |s|
s.name = 'ExpoBlueskyGifView'
s.version = '1.0.0'
s.summary = 'A simple GIF player for Bluesky'
s.description = 'A simple GIF player for Bluesky'
s.author = ''
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'SDWebImage', '~> 5.17.0'
s.dependency 'SDWebImageWebPCoder', '~> 0.13.0'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,47 @@
import ExpoModulesCore
import SDWebImage
import SDWebImageWebPCoder
public class ExpoBlueskyGifViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoBlueskyGifView")
OnCreate {
SDImageCodersManager.shared.addCoder(SDImageGIFCoder.shared)
}
AsyncFunction("prefetchAsync") { (sources: [URL]) in
SDWebImagePrefetcher.shared.prefetchURLs(sources, context: Util.createContext(), progress: nil)
}
View(GifView.self) {
Events(
"onPlayerStateChange"
)
Prop("source") { (view: GifView, prop: String) in
view.source = prop
}
Prop("placeholderSource") { (view: GifView, prop: String) in
view.placeholderSource = prop
}
Prop("autoplay") { (view: GifView, prop: Bool) in
view.autoplay = prop
}
AsyncFunction("toggleAsync") { (view: GifView) in
view.toggle()
}
AsyncFunction("playAsync") { (view: GifView) in
view.play()
}
AsyncFunction("pauseAsync") { (view: GifView) in
view.pause()
}
}
}
}
@@ -0,0 +1,185 @@
import ExpoModulesCore
import SDWebImage
import SDWebImageWebPCoder
typealias SDWebImageContext = [SDWebImageContextOption: Any]
public class GifView: ExpoView, AVPlayerViewControllerDelegate {
// Events
private let onPlayerStateChange = EventDispatcher()
// SDWebImage
private let imageView = SDAnimatedImageView(frame: .zero)
private let imageManager = SDWebImageManager(
cache: SDImageCache.shared,
loader: SDImageLoadersManager.shared
)
private var isPlaying = true
private var isLoaded = false
// Requests
private var webpOperation: SDWebImageCombinedOperation?
private var placeholderOperation: SDWebImageCombinedOperation?
// Props
var source: String? = nil
var placeholderSource: String? = nil
var autoplay = true {
didSet {
if !autoplay {
self.pause()
} else {
self.play()
}
}
}
// MARK: - Lifecycle
public required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
self.clipsToBounds = true
self.imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.imageView.layer.masksToBounds = false
self.imageView.backgroundColor = .clear
self.imageView.contentMode = .scaleToFill
// We have to explicitly set this to false. If we don't, every time
// the view comes into the viewport, it will start animating again
self.imageView.autoPlayAnimatedImage = false
self.addSubview(self.imageView)
}
public override func willMove(toWindow newWindow: UIWindow?) {
if newWindow == nil {
// Don't cancel the placeholder operation, because we really want that to complete for
// when we scroll back up
self.webpOperation?.cancel()
self.placeholderOperation?.cancel()
} else if self.imageView.image == nil {
self.load()
}
}
// MARK: - Loading
private func load() {
guard let source = self.source, let placeholderSource = self.placeholderSource else {
return
}
self.webpOperation?.cancel()
self.placeholderOperation?.cancel()
// We only need to start an operation for the placeholder if it doesn't exist
// in the cache already. Cache key is by default the absolute URL of the image.
// See:
// https://github.com/SDWebImage/SDWebImage/blob/master/Docs/HowToUse.md#using-asynchronous-image-caching-independently
if !SDImageCache.shared.diskImageDataExists(withKey: source),
let url = URL(string: placeholderSource)
{
self.placeholderOperation = imageManager.loadImage(
with: url,
options: [.retryFailed],
context: Util.createContext(),
progress: onProgress(_:_:_:),
completed: onLoaded(_:_:_:_:_:_:)
)
}
if let url = URL(string: source) {
self.webpOperation = imageManager.loadImage(
with: url,
options: [.retryFailed],
context: Util.createContext(),
progress: onProgress(_:_:_:),
completed: onLoaded(_:_:_:_:_:_:)
)
}
}
private func setImage(_ image: UIImage) {
if self.imageView.image == nil || image.sd_isAnimated {
self.imageView.image = image
}
if image.sd_isAnimated {
self.firePlayerStateChange()
if isPlaying {
self.imageView.startAnimating()
}
}
}
// MARK: - Loading blocks
private func onProgress(_ receivedSize: Int, _ expectedSize: Int, _ imageUrl: URL?) {}
private func onLoaded(
_ image: UIImage?,
_ data: Data?,
_ error: Error?,
_ cacheType: SDImageCacheType,
_ finished: Bool,
_ imageUrl: URL?
) {
guard finished else {
return
}
if let placeholderSource = self.placeholderSource,
imageUrl?.absoluteString == placeholderSource,
self.imageView.image == nil,
let image = image
{
self.setImage(image)
return
}
if let source = self.source,
imageUrl?.absoluteString == source,
// UIImage perf suckssss if the image is animated
let data = data,
let animatedImage = SDAnimatedImage(data: data)
{
self.placeholderOperation?.cancel()
self.isPlaying = self.autoplay
self.isLoaded = true
self.setImage(animatedImage)
self.firePlayerStateChange()
}
}
// MARK: - Playback Controls
func play() {
self.imageView.startAnimating()
self.isPlaying = true
self.firePlayerStateChange()
}
func pause() {
self.imageView.stopAnimating()
self.isPlaying = false
self.firePlayerStateChange()
}
func toggle() {
if self.isPlaying {
self.pause()
} else {
self.play()
}
}
// MARK: - Util
private func firePlayerStateChange() {
onPlayerStateChange([
"isPlaying": self.isPlaying,
"isLoaded": self.isLoaded
])
}
}
@@ -0,0 +1,17 @@
import SDWebImage
class Util {
static func createContext() -> SDWebImageContext {
var context = SDWebImageContext()
// SDAnimatedImage for some reason has issues whenever loaded from memory. Instead, we
// will just use the disk. SDWebImage will manage this cache for us, so we don't need
// to worry about clearing it.
context[.originalQueryCacheType] = SDImageCacheType.disk.rawValue
context[.originalStoreCacheType] = SDImageCacheType.disk.rawValue
context[.queryCacheType] = SDImageCacheType.disk.rawValue
context[.storeCacheType] = SDImageCacheType.disk.rawValue
return context
}
}
@@ -0,0 +1,39 @@
import React from 'react'
import {requireNativeModule} from 'expo'
import {requireNativeViewManager} from 'expo-modules-core'
import {GifViewProps} from './GifView.types'
const NativeModule = requireNativeModule('ExpoBlueskyGifView')
const NativeView: React.ComponentType<
GifViewProps & {ref: React.RefObject<any>}
> = requireNativeViewManager('ExpoBlueskyGifView')
export class GifView extends React.PureComponent<GifViewProps> {
// TODO native types, should all be the same as those in this class
private nativeRef: React.RefObject<any> = React.createRef()
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
static async prefetchAsync(sources: string[]): Promise<void> {
return await NativeModule.prefetchAsync(sources)
}
async playAsync(): Promise<void> {
await this.nativeRef.current.playAsync()
}
async pauseAsync(): Promise<void> {
await this.nativeRef.current.pauseAsync()
}
async toggleAsync(): Promise<void> {
await this.nativeRef.current.toggleAsync()
}
render() {
return <NativeView {...this.props} ref={this.nativeRef} />
}
}
@@ -0,0 +1,15 @@
import {ViewProps} from 'react-native'
export interface GifViewStateChangeEvent {
nativeEvent: {
isPlaying: boolean
isLoaded: boolean
}
}
export interface GifViewProps extends ViewProps {
autoplay?: boolean
source?: string
placeholderSource?: string
onPlayerStateChange?: (event: GifViewStateChangeEvent) => void
}
@@ -0,0 +1,82 @@
import * as React from 'react'
import {StyleSheet} from 'react-native'
import {GifViewProps} from './GifView.types'
export class GifView extends React.PureComponent<GifViewProps> {
private readonly videoPlayerRef: React.RefObject<HTMLMediaElement> =
React.createRef()
private isLoaded = false
constructor(props: GifViewProps | Readonly<GifViewProps>) {
super(props)
}
componentDidUpdate(prevProps: Readonly<GifViewProps>) {
if (prevProps.autoplay !== this.props.autoplay) {
if (this.props.autoplay) {
this.playAsync()
} else {
this.pauseAsync()
}
}
}
static async prefetchAsync(_: string[]): Promise<void> {
console.warn('prefetchAsync is not supported on web')
}
private firePlayerStateChangeEvent = () => {
this.props.onPlayerStateChange?.({
nativeEvent: {
isPlaying: !this.videoPlayerRef.current?.paused,
isLoaded: this.isLoaded,
},
})
}
private onLoad = () => {
// Prevent multiple calls to onLoad because onCanPlay will fire after each loop
if (this.isLoaded) {
return
}
this.isLoaded = true
this.firePlayerStateChangeEvent()
}
async playAsync(): Promise<void> {
this.videoPlayerRef.current?.play()
}
async pauseAsync(): Promise<void> {
this.videoPlayerRef.current?.pause()
}
async toggleAsync(): Promise<void> {
if (this.videoPlayerRef.current?.paused) {
await this.playAsync()
} else {
await this.pauseAsync()
}
}
render() {
return (
<video
src={this.props.source}
autoPlay={this.props.autoplay ? 'autoplay' : undefined}
preload={this.props.autoplay ? 'auto' : undefined}
playsInline={true}
loop="loop"
muted="muted"
style={StyleSheet.flatten(this.props.style)}
onCanPlay={this.onLoad}
onPlay={this.firePlayerStateChangeEvent}
onPause={this.firePlayerStateChangeEvent}
aria-label={this.props.accessibilityLabel}
ref={this.videoPlayerRef}
/>
)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollForwarderModule"]
}
}
+1
View File
@@ -0,0 +1 @@
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollForwarder'
s.version = '1.0.0'
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
s.description = 'Forward scroll gesture from UIView to UIScrollView'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,13 @@
import ExpoModulesCore
public class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop
}
}
}
}
@@ -0,0 +1,215 @@
import ExpoModulesCore
// This view will be used as a native component. Make sure to inherit from `ExpoView`
// to apply the proper styling (e.g. border radius and shadows).
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
var scrollViewTag: Int? {
didSet {
self.tryFindScrollView()
}
}
private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self
self.addGestureRecognizer(pg)
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tg.isEnabled = false
tg.delegate = self
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
lpg.minimumPressDuration = 0.01
lpg.isEnabled = false
lpg.delegate = self
self.cancelGestureRecognizers = [lpg, tg]
}
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x)
}
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
// recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.stopTimer()
}
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
self.stopTimer()
}
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return
}
let translation = sender.translation(in: self).y
if sender.state == .began {
if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0
}
self.initialOffset = sv.contentOffset.y
}
if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
self.didImpact = true
}
}
if sender.state == .ended {
let velocity = sender.velocity(in: self).y
self.didImpact = false
if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing()
return
}
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
// scroll view
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return
}
self.startDecayAnimation(translation, velocity)
}
}
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else {
return
}
var velocity = velocity
self.enableCancelGestureRecognizers()
if velocity > 0 {
velocity = min(velocity, 5000)
} else {
velocity = max(velocity, -5000)
}
var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 {
if self.initialOffset <= 1 {
self.scrollToOffset(0)
} else {
sv.contentOffset.y = 0
}
self.stopTimer()
return
} else {
sv.contentOffset.y = nextOffset
}
if abs(velocity) < 5 {
self.stopTimer()
}
}
}
func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 {
return offset - (offset * 0.55)
}
return offset
}
func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else {
return
}
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
// Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers()
}
func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
}
}
func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
}
}
func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true
}
}
func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false
}
}
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
}
func stopTimer() -> Void {
self.disableCancelGestureRecognizers()
self.animTimer?.invalidate()
self.animTimer = nil
}
}
@@ -0,0 +1,6 @@
import React from 'react'
export interface ExpoScrollForwarderViewProps {
scrollViewTag: number | null
children: React.ReactNode
}
@@ -0,0 +1,14 @@
import * as React from 'react'
import {requireNativeViewManager} from 'expo-modules-core'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
requireNativeViewManager('ExpoScrollForwarder')
export function ExpoScrollForwarderView({
children,
...rest
}: ExpoScrollForwarderViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}
@@ -0,0 +1,8 @@
import React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
export function ExpoScrollForwarderView({
children,
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
return children
}