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:
@@ -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>
|
||||
+37
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+180
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user