React Native New Arch (#10980)
This commit is contained in:
@@ -55,7 +55,6 @@ module.exports = function (_config) {
|
||||
icon: './assets/app-icons/ios_icon_default_next.png',
|
||||
userInterfaceStyle: 'automatic',
|
||||
primaryColor: '#006AFF',
|
||||
newArchEnabled: false,
|
||||
ios: {
|
||||
supportsTablet: false,
|
||||
bundleIdentifier: 'xyz.blueskyweb.app',
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ module.exports = function (api) {
|
||||
},
|
||||
},
|
||||
],
|
||||
'react-native-reanimated/plugin', // NOTE: this plugin MUST be last
|
||||
'react-native-worklets/plugin', // NOTE: this plugin MUST be last
|
||||
],
|
||||
env: {
|
||||
production: {
|
||||
|
||||
@@ -6,7 +6,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
// Views
|
||||
private var sheetVc: SheetViewController?
|
||||
private var innerView: UIView?
|
||||
private var touchHandler: RCTTouchHandler?
|
||||
private var touchHandler: RCTSurfaceTouchHandler?
|
||||
|
||||
// Native content height observation (eliminates JS bridge round-trip)
|
||||
private var contentHeightObservation: NSKeyValueObservation?
|
||||
@@ -81,33 +81,37 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
required init (appContext: AppContext? = nil) {
|
||||
super.init(appContext: appContext)
|
||||
self.maxHeight = Util.getScreenHeight() ?? UIScreen.main.bounds.height
|
||||
self.touchHandler = RCTTouchHandler(bridge: appContext?.reactBridge)
|
||||
self.touchHandler = RCTSurfaceTouchHandler()
|
||||
SheetManager.shared.add(self)
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.destroy()
|
||||
}
|
||||
|
||||
override func mountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
self.innerView = childComponentView
|
||||
touchHandler?.attach(to: childComponentView)
|
||||
}
|
||||
|
||||
override func unmountChildComponentView(
|
||||
_ childComponentView: UIView,
|
||||
index: Int
|
||||
) {
|
||||
touchHandler?.detach(from: childComponentView)
|
||||
|
||||
// We don't want this view to actually get added to the tree, so we'll simply store it for adding
|
||||
// to the SheetViewController
|
||||
override func insertReactSubview(_ subview: UIView!, at atIndex: Int) {
|
||||
self.touchHandler?.attach(to: subview)
|
||||
self.innerView = subview
|
||||
childComponentView.removeFromSuperview()
|
||||
if self.innerView === childComponentView {
|
||||
self.innerView = nil
|
||||
}
|
||||
}
|
||||
|
||||
// We'll grab the content height from here so we know the initial detent to set
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
guard let innerView = self.innerView else {
|
||||
return
|
||||
}
|
||||
|
||||
if innerView.subviews.count != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
self.present()
|
||||
}
|
||||
|
||||
@@ -117,7 +121,10 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
self.isClosing = false
|
||||
self.isOpen = false
|
||||
self.sheetVc = nil
|
||||
self.touchHandler?.detach(from: self.innerView)
|
||||
|
||||
if let innerView = self.innerView {
|
||||
self.touchHandler?.detach(from: innerView)
|
||||
}
|
||||
self.touchHandler = nil
|
||||
self.innerView = nil
|
||||
SheetManager.shared.remove(self)
|
||||
@@ -146,8 +153,7 @@ class SheetView: ExpoView, UISheetPresentationControllerDelegate {
|
||||
|
||||
if #available(iOS 26.0, *),
|
||||
let tag = self.sourceViewTag,
|
||||
let bridge = self.appContext?.reactBridge,
|
||||
let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: tag)) {
|
||||
let sourceView = self.appContext?.findView(withTag: tag, ofType: UIView.self) {
|
||||
sheetVc.preferredTransition = .zoom { _ in
|
||||
return sourceView
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#if __has_include(<React/RCTSurfaceTouchHandler.h>)
|
||||
#import <React/RCTSurfaceTouchHandler.h>
|
||||
#endif
|
||||
@@ -1,116 +0,0 @@
|
||||
# expo-scroll-forwarder
|
||||
|
||||
An Expo native module that forwards scroll gestures from a UIView to a UIScrollView on iOS. This enables custom scroll behaviors by allowing a non-scrollable view to control a scrollable view's scroll position.
|
||||
|
||||
## What It Does
|
||||
|
||||
This module solves a specific interaction problem: allowing a fixed header or overlay view to respond to scroll gestures and forward them to an underlying scroll view. The primary use case in the Bluesky app is the profile screen, where the profile header sits above a scrollable content area and can be dragged to scroll the content below it.
|
||||
|
||||
Key behaviors:
|
||||
- Captures pan gestures on a wrapper view and translates them to scroll offsets on a target scroll view
|
||||
- Implements physics-based deceleration animations that match native scroll behavior
|
||||
- Supports pull-to-refresh interactions with haptic feedback
|
||||
- Prevents gesture conflicts with iOS swipe-back navigation by only activating on vertical pans
|
||||
- Provides rubber-band damping when scrolling past content bounds
|
||||
|
||||
## Architecture
|
||||
|
||||
The module consists of three main parts:
|
||||
|
||||
### 1. Native iOS Implementation (Swift)
|
||||
|
||||
**ExpoScrollForwarderView.swift** - The core native view component that:
|
||||
- Attaches a UIPanGestureRecognizer to intercept scroll gestures
|
||||
- Finds and references the target RCTScrollView using its React Native tag
|
||||
- Implements custom scroll physics including velocity-based decay animation
|
||||
- Manages gesture recognizer delegation to prevent conflicts with system gestures
|
||||
- Handles pull-to-refresh activation at -130pt scroll offset with haptic feedback
|
||||
|
||||
**ExpoScrollForwarderModule.swift** - The Expo module definition that:
|
||||
- Registers the view component with Expo
|
||||
- Exposes the `scrollViewTag` prop to specify which scroll view to control
|
||||
|
||||
### 2. TypeScript Interface
|
||||
|
||||
**ExpoScrollForwarderView.tsx** - Platform-specific implementations:
|
||||
- **iOS (.ios.tsx)**: Wraps the native view manager from expo-modules-core
|
||||
- **Default (.tsx)**: No-op wrapper that just renders children (for Android/Web compatibility)
|
||||
|
||||
**ExpoScrollForwarder.types.ts** - TypeScript type definitions:
|
||||
- `scrollViewTag`: The React Native tag of the scroll view to control
|
||||
- `children`: The content to render (typically a header component)
|
||||
|
||||
### 3. Module Configuration
|
||||
|
||||
**expo-module.config.json** - Declares iOS-only platform support
|
||||
|
||||
**ExpoScrollForwarder.podspec** - CocoaPods specification for iOS dependency management
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import {ExpoScrollForwarderView} from 'expo-scroll-forwarder'
|
||||
|
||||
function ProfileScreen() {
|
||||
const scrollViewTag = useRef(null)
|
||||
|
||||
return (
|
||||
<View>
|
||||
<ExpoScrollForwarderView scrollViewTag={scrollViewTag.current}>
|
||||
<ProfileHeader />
|
||||
</ExpoScrollForwarderView>
|
||||
|
||||
<ScrollView ref={scrollViewTag}>
|
||||
{/* Scrollable content */}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The `scrollViewTag` prop must be the React Native tag (numeric identifier) of the target scroll view. The module uses this to locate the native UIScrollView instance.
|
||||
|
||||
## Platform Support
|
||||
|
||||
- **iOS**: Full native implementation with custom scroll physics
|
||||
- **Android**: No-op wrapper (renders children without scroll forwarding)
|
||||
- **Web**: No-op wrapper (renders children without scroll forwarding)
|
||||
|
||||
The module is designed to enhance iOS UX while gracefully degrading on other platforms.
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### Gesture Recognition
|
||||
- Only activates when pan velocity is more vertical than horizontal (`abs(velocity.y) > abs(velocity.x)`)
|
||||
- Delegates to UIGestureRecognizerDelegate to prevent simultaneous recognition with navigation swipe-back
|
||||
- Adds tap/long-press recognizers to the scroll view to cancel ongoing animations
|
||||
|
||||
### Scroll Physics
|
||||
- Implements custom decay animation at 120fps using a Timer
|
||||
- Velocity decay factor: 0.9875 per frame
|
||||
- Velocity clamped to +/- 5000 points/second
|
||||
- Rubber-band damping: offsets below 0 are reduced by 55%
|
||||
- Animation stops when velocity drops below 5 points/second
|
||||
|
||||
### Pull-to-Refresh
|
||||
- Triggers at -130pt scroll offset
|
||||
- Provides haptic feedback (UIImpactFeedbackGenerator, light style)
|
||||
- Calls refresh control via `RCTRefreshControl.forwarderBeginRefreshing()`
|
||||
|
||||
### Scroll View Management
|
||||
- Dynamically finds scroll view using `AppContext.findView(withTag:ofType:)`
|
||||
- Properly cleans up gesture recognizers when switching between scroll views
|
||||
- Maintains references to both the scroll view and its refresh control
|
||||
|
||||
## Files Overview
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `ios/ExpoScrollForwarderView.swift` | Native iOS view implementation with gesture handling and scroll physics |
|
||||
| `ios/ExpoScrollForwarderModule.swift` | Expo module registration and prop definitions |
|
||||
| `ios/ExpoScrollForwarder.podspec` | CocoaPods dependency specification |
|
||||
| `src/ExpoScrollForwarderView.ios.tsx` | TypeScript wrapper for iOS native view |
|
||||
| `src/ExpoScrollForwarderView.tsx` | Default no-op implementation for other platforms |
|
||||
| `src/ExpoScrollForwarder.types.ts` | TypeScript type definitions |
|
||||
| `index.ts` | Module entry point |
|
||||
| `expo-module.config.json` | Expo module configuration |
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"platforms": ["ios"],
|
||||
"ios": {
|
||||
"modules": ["ExpoScrollForwarderModule"]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
import ExpoModulesCore
|
||||
import React
|
||||
|
||||
// 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) {
|
||||
self.stopTimer()
|
||||
}
|
||||
|
||||
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
|
||||
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) { _ 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) {
|
||||
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
self.disableCancelGestureRecognizers()
|
||||
self.animTimer?.invalidate()
|
||||
self.animTimer = nil
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ExpoScrollForwarderViewProps {
|
||||
scrollViewTag: number | null
|
||||
children: React.ReactNode
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {requireNativeViewManager} from 'expo-modules-core'
|
||||
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
|
||||
requireNativeViewManager('ExpoScrollForwarder')
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
...rest
|
||||
}: ExpoScrollForwarderViewProps) {
|
||||
return <NativeView {...rest}>{children}</NativeView>
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
|
||||
|
||||
export function ExpoScrollForwarderView({
|
||||
children,
|
||||
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
require "json"
|
||||
|
||||
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "ScrollForwarder"
|
||||
s.version = package["version"]
|
||||
s.summary = package["description"]
|
||||
s.homepage = package["homepage"]
|
||||
s.license = package["license"]
|
||||
s.authors = package["author"]
|
||||
|
||||
s.platforms = { :ios => min_ios_version_supported }
|
||||
s.source = { :git => ".git", :tag => "#{s.version}" }
|
||||
|
||||
s.source_files = "ios/**/*.{h,m,mm,cpp}"
|
||||
s.private_header_files = "ios/**/*.h"
|
||||
|
||||
install_modules_dependencies(s)
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
#import <React/RCTViewComponentView.h>
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
#ifndef ScrollForwarderViewNativeComponent_h
|
||||
#define ScrollForwarderViewNativeComponent_h
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface ScrollForwarderView : RCTViewComponentView
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
#endif /* ScrollForwarderViewNativeComponent_h */
|
||||
@@ -0,0 +1,411 @@
|
||||
#import "ScrollForwarderView.h"
|
||||
|
||||
#import <React/RCTEnhancedScrollView.h>
|
||||
#import <React/RCTScrollViewComponentView.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/ComponentDescriptors.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/EventEmitters.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/Props.h>
|
||||
#import <react/renderer/components/ScrollForwarderViewSpec/RCTComponentViewHelpers.h>
|
||||
|
||||
#import "RCTFabricComponentsPlugins.h"
|
||||
|
||||
using namespace facebook::react;
|
||||
|
||||
// How far down a pull needs to be to trigger a refresh
|
||||
static const CGFloat kPullThreshold = 130.0;
|
||||
static const CGFloat kDampingFactor = 0.55;
|
||||
// The top speed that free scrolling can have
|
||||
static const CGFloat kMaxVelocity = 5000.0;
|
||||
// Free scrolling decay. This seems to be close to the default iOS value
|
||||
static const CGFloat kVelocityDecay = 0.9875;
|
||||
// What scroll release velocity will actually trigger free scrolling
|
||||
static const CGFloat kMinimumVelocity = 5.0;
|
||||
|
||||
@interface ScrollForwarderView () <RCTScrollForwarderViewViewProtocol, UIGestureRecognizerDelegate>
|
||||
|
||||
@end
|
||||
|
||||
@implementation ScrollForwarderView {
|
||||
NSArray<UIGestureRecognizer *> * _cancelGestureRecognizers;
|
||||
RCTScrollViewComponentView * _svcv;
|
||||
CGPoint _initialOffset;
|
||||
|
||||
CADisplayLink * _displayLink;
|
||||
CGFloat _currentVelocity;
|
||||
CGFloat _accumulatedTranslation;
|
||||
|
||||
bool _didImpact;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
{
|
||||
return concreteComponentDescriptorProvider<ScrollForwarderViewComponentDescriptor>();
|
||||
}
|
||||
|
||||
- (instancetype)initWithFrame:(CGRect)frame
|
||||
{
|
||||
if (self = [super initWithFrame:frame]) {
|
||||
static const auto defaultProps = std::make_shared<const ScrollForwarderViewProps>();
|
||||
_props = defaultProps;
|
||||
|
||||
UIPanGestureRecognizer *pg = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handlePan:)];
|
||||
pg.delegate = self;
|
||||
pg.cancelsTouchesInView = false;
|
||||
[self addGestureRecognizer:pg];
|
||||
|
||||
UITapGestureRecognizer *tg = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap:)];
|
||||
[tg setEnabled:false];
|
||||
tg.delegate = self;
|
||||
|
||||
UILongPressGestureRecognizer *lpg = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
[lpg setMinimumPressDuration:0.01];
|
||||
[lpg setEnabled:false];
|
||||
lpg.delegate = self;
|
||||
|
||||
NSArray<UIGestureRecognizer *> *cancelGestureRecognizers = [NSArray arrayWithObjects:lpg, tg, nil];
|
||||
_cancelGestureRecognizers = cancelGestureRecognizers;
|
||||
}
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
- (void)dealloc
|
||||
{
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
gr.delegate = nil;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)prepareForRecycle
|
||||
{
|
||||
[super prepareForRecycle];
|
||||
[self stopAnimation];
|
||||
[self removeCancelGestureRecognizers];
|
||||
_svcv = nil;
|
||||
}
|
||||
|
||||
// MARK: - Props
|
||||
|
||||
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps
|
||||
{
|
||||
const auto &oldViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(_props);
|
||||
const auto &newViewProps = *std::static_pointer_cast<ScrollForwarderViewProps const>(props);
|
||||
|
||||
if (oldViewProps.scrollViewTag != newViewProps.scrollViewTag) {
|
||||
[self tryFindScrollView];
|
||||
}
|
||||
|
||||
if (oldViewProps.refreshing != newViewProps.refreshing) {
|
||||
if (!newViewProps.refreshing) {
|
||||
[self endRefreshing];
|
||||
}
|
||||
}
|
||||
|
||||
[super updateProps:props oldProps:oldProps];
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer
|
||||
{
|
||||
if ([gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]] && [otherGestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer
|
||||
{
|
||||
if (![gestureRecognizer isKindOfClass:[UIPanGestureRecognizer class]]) {
|
||||
return YES;
|
||||
}
|
||||
|
||||
UIPanGestureRecognizer *pg = (UIPanGestureRecognizer *)gestureRecognizer;
|
||||
CGPoint velocity = [pg velocityInView:self];
|
||||
|
||||
return fabs(velocity.y) > fabs(velocity.x);
|
||||
}
|
||||
|
||||
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
|
||||
{
|
||||
[self stopAnimation];
|
||||
[super touchesBegan:touches withEvent:event];
|
||||
}
|
||||
|
||||
// MARK: - Scroll Forwarding
|
||||
|
||||
- (void)removeCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView removeGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)addCancelGestureRecognizers
|
||||
{
|
||||
if (!_svcv) return;
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[_svcv.scrollView addGestureRecognizer:gr];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)enableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:true];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)disableCancelGestureRecognizers
|
||||
{
|
||||
for (UIGestureRecognizer *gr in _cancelGestureRecognizers) {
|
||||
[gr setEnabled:false];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)scrollToOffset:(CGPoint)offset animated:(bool)animated
|
||||
{
|
||||
if (!_svcv) return;
|
||||
[_svcv scrollToOffset:offset animated:animated];
|
||||
}
|
||||
|
||||
- (void)stopAnimation
|
||||
{
|
||||
[self disableCancelGestureRecognizers];
|
||||
[_displayLink invalidate];
|
||||
_displayLink = nil;
|
||||
}
|
||||
|
||||
- (void)handlePan:(UIPanGestureRecognizer *)gesture {
|
||||
if (!_svcv) return;
|
||||
|
||||
UIScrollView *sv = _svcv.scrollView;
|
||||
|
||||
CGPoint translation = [gesture translationInView:self];
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateBegan) {
|
||||
_didImpact = false;
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
sv.contentOffset = newOffset;
|
||||
}
|
||||
|
||||
_initialOffset = sv.contentOffset;
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateChanged) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, [self dampenOffset:(-translation.y + _initialOffset.y)]);
|
||||
sv.contentOffset = newOffset;
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold && !_didImpact) {
|
||||
UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[generator impactOccurred];
|
||||
_didImpact = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gesture.state == UIGestureRecognizerStateEnded) {
|
||||
CGPoint velocity = [gesture velocityInView:self];
|
||||
|
||||
if (sv.contentOffset.y <= -kPullThreshold) {
|
||||
[self refresh];
|
||||
return;
|
||||
}
|
||||
|
||||
if (sv.contentOffset.y < 0) {
|
||||
CGPoint newOffset = CGPointMake(sv.contentOffset.x, 0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
return;
|
||||
}
|
||||
|
||||
if (abs(velocity.y) < 250 && sv.contentOffset.y >= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
[self startDecayWithInitialTranslation:translation.y velocity:velocity.y];
|
||||
}
|
||||
}
|
||||
|
||||
- (CGFloat)dampenOffset:(CGFloat)offset
|
||||
{
|
||||
if (offset < 0) {
|
||||
return offset - (offset * kDampingFactor);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UITapGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)handleLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
[self stopAnimation];
|
||||
}
|
||||
|
||||
- (void)startDecayWithInitialTranslation:(CGFloat)translation velocity:(CGFloat)startVelocity
|
||||
{
|
||||
if (!_svcv) return;
|
||||
|
||||
startVelocity = MAX(-kMaxVelocity, MIN(kMaxVelocity, startVelocity));
|
||||
_currentVelocity = startVelocity;
|
||||
_accumulatedTranslation = -translation;
|
||||
|
||||
[self enableCancelGestureRecognizers];
|
||||
|
||||
[_displayLink invalidate];
|
||||
|
||||
CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(handleDecayStep:)];
|
||||
|
||||
link.preferredFramesPerSecond = 60;
|
||||
[link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
|
||||
_displayLink = link;
|
||||
}
|
||||
|
||||
- (void)handleDecayStep:(CADisplayLink *)link
|
||||
{
|
||||
_currentVelocity *= kVelocityDecay;
|
||||
|
||||
CGFloat delta = -_currentVelocity / link.preferredFramesPerSecond;
|
||||
_accumulatedTranslation += delta;
|
||||
|
||||
CGFloat rawY = _accumulatedTranslation + _initialOffset.y;
|
||||
CGFloat nextY = rawY > 0 ? rawY : 0;
|
||||
|
||||
CGPoint newOffset = CGPointMake(
|
||||
_svcv.scrollView.contentOffset.x,
|
||||
nextY
|
||||
);
|
||||
_svcv.scrollView.contentOffset = newOffset;
|
||||
|
||||
if (fabs(_currentVelocity) < kMinimumVelocity || nextY <= 0) {
|
||||
[link invalidate];
|
||||
_displayLink = nil;
|
||||
[self disableCancelGestureRecognizers];
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* We use this component on profile pages. The screne consists of a header component, a scrollview with buttons to
|
||||
* switch between profile tabs, and a pager view (RNCPagerViewComponentView). Both the header and the tab bar are
|
||||
* inside the same RCTViewComponentView. The view heirarchy looks something like this:
|
||||
* - RCTViewComponentView
|
||||
* -- RNCPagerViewComponentView
|
||||
* ----- (Many views deep) RCTScrollViewComponentView
|
||||
* ------ RCTEnhancedScrollView
|
||||
* -- RCTViewComponentView
|
||||
* --- RCTViewComponentView
|
||||
* ---- ScrollForwarderView
|
||||
* --- RCTScrollViewComponentView
|
||||
* ---- RCTEnhancedScrollView
|
||||
*
|
||||
* We want to find that RCTScrollViewComponentView inside of the RNCPagerViewComponentView. To achieve this, we can
|
||||
* use self.superview.superview.superview to get to the root RCTViewComponentView, find the RNCPagerViewComponentView,
|
||||
* then iterate through that view's subviews until we find a RCTScrollViewComponentView.
|
||||
*
|
||||
* This isn't great, because if we reorder the React components, we'll need to update this logic. There's probably
|
||||
* an easier way to achieve this, similar to how we used to do it in Paper (ie, get the scroll view's tag and find that),
|
||||
* but this also comes with some benefits, eg being able to reduce a lot of the logic in the JS code and just find the
|
||||
* scrollview when subviews change.
|
||||
*/
|
||||
- (void)tryFindScrollView
|
||||
{
|
||||
[self removeCancelGestureRecognizers];
|
||||
|
||||
// The root RCTViewComponentView
|
||||
UIView *rootView = self.superview.superview.superview;
|
||||
UIView *pagerView;
|
||||
|
||||
NSString *targetClsName = @"RNCPagerViewComponentView";
|
||||
Class targetCls = NSClassFromString(targetClsName);
|
||||
|
||||
for (UIView *subview in rootView.subviews) {
|
||||
if ([subview isKindOfClass:targetCls]) {
|
||||
pagerView = subview;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pagerView) return;
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:pagerView];
|
||||
|
||||
if (!svcv) return;
|
||||
|
||||
_svcv = svcv;
|
||||
[self addCancelGestureRecognizers];
|
||||
}
|
||||
|
||||
- (RCTScrollViewComponentView *)findRTCScrollViewComponentViewInView:(UIView *)view
|
||||
{
|
||||
for (UIView *subview in view.subviews) {
|
||||
if ([subview isKindOfClass:[RCTScrollViewComponentView class]]) {
|
||||
RCTScrollViewComponentView *svcv = (RCTScrollViewComponentView *) subview;
|
||||
return svcv;
|
||||
}
|
||||
|
||||
RCTScrollViewComponentView *svcv = [self findRTCScrollViewComponentViewInView:subview];
|
||||
if (svcv) return svcv;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIRefreshControl *)refreshContorl
|
||||
{
|
||||
if (!_svcv) return nil;
|
||||
return _svcv.scrollView.refreshControl;
|
||||
}
|
||||
|
||||
- (void)refresh
|
||||
{
|
||||
__weak ScrollForwarderView *weakSelf = self;
|
||||
|
||||
[_svcv.scrollView.refreshControl beginRefreshing];
|
||||
|
||||
[UIView animateWithDuration:0.3
|
||||
delay:0
|
||||
options:UIViewAnimationOptionBeginFromCurrentState
|
||||
animations:^(void) {
|
||||
if (!weakSelf) return;
|
||||
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
// Whenever we call this method, the scrollview will always be at a position of
|
||||
// -130 or less. Scrolling back to -80 simulates the default behavior of RCTRefreshControl
|
||||
[self->_svcv.scrollView setContentOffset:CGPointMake(0, -65)];
|
||||
}
|
||||
completion:^(__unused BOOL finished) {
|
||||
__strong ScrollForwarderView *self = weakSelf;
|
||||
|
||||
if (self->_eventEmitter != nullptr) {
|
||||
std::dynamic_pointer_cast<const facebook::react::ScrollForwarderViewEventEmitter>(self->_eventEmitter)
|
||||
->onRefresh(facebook::react::ScrollForwarderViewEventEmitter::OnRefresh{});
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
- (void)endRefreshing
|
||||
{
|
||||
UIRefreshControl *rc = [self refreshContorl];
|
||||
|
||||
CGPoint newOffset = CGPointMake(_svcv.scrollView.contentOffset.x, 0.0);
|
||||
[self scrollToOffset:newOffset animated:true];
|
||||
|
||||
[rc endRefreshing];
|
||||
}
|
||||
|
||||
Class<RCTComponentViewProtocol> ScrollForwarderViewCls(void)
|
||||
{
|
||||
return ScrollForwarderView.class;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "react-native-scroll-forwarder",
|
||||
"version": "0.0.0",
|
||||
"description": "Scroll forwarder module for Bluesky profile headers",
|
||||
"main": "src/index",
|
||||
"codegenConfig": {
|
||||
"name": "ScrollForwarderViewSpec",
|
||||
"type": "all",
|
||||
"jsSrcsDir": "src",
|
||||
"ios": {
|
||||
"componentProvider": {
|
||||
"ScrollForwarderView": "ScrollForwarderView"
|
||||
}
|
||||
}
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-native": "*"
|
||||
},
|
||||
"author": "Hailey <me@haileyok.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "#readme",
|
||||
"create-react-native-library": {
|
||||
"languages": "kotlin-objc",
|
||||
"type": "fabric-view",
|
||||
"version": "0.50.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {
|
||||
default as NativeScrollForwarderView,
|
||||
type NativeProps,
|
||||
} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children, ...rest}: NativeProps) {
|
||||
return (
|
||||
<NativeScrollForwarderView {...rest} style={{flex: 1}}>
|
||||
{children}
|
||||
</NativeScrollForwarderView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {type NativeProps} from './ScrollForwarderViewNativeComponent'
|
||||
|
||||
export function ScrollForwarderView({children}: NativeProps) {
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
codegenNativeComponent,
|
||||
type CodegenTypes,
|
||||
type ViewProps,
|
||||
} from 'react-native'
|
||||
|
||||
type OnRefreshEvent = {}
|
||||
|
||||
export interface NativeProps extends ViewProps {
|
||||
scrollViewTag: CodegenTypes.Int32 | null
|
||||
refreshing?: boolean
|
||||
onRefresh?: CodegenTypes.BubblingEventHandler<OnRefreshEvent>
|
||||
}
|
||||
|
||||
export default codegenNativeComponent<NativeProps>('ScrollForwarderView')
|
||||
@@ -0,0 +1,2 @@
|
||||
export {ScrollForwarderView} from './ScrollForwarderView'
|
||||
export * from './ScrollForwarderViewNativeComponent'
|
||||
+10
-8
@@ -105,10 +105,9 @@
|
||||
"@bsky.app/expo-dynamic-app-icon": "^1.8.5",
|
||||
"@bsky.app/expo-guess-language": "^0.2.8",
|
||||
"@bsky.app/expo-image-crop-tool": "^0.5.1",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.4",
|
||||
"@bsky.app/expo-scroll-edge-effect": "^0.1.9",
|
||||
"@bsky.app/expo-translate-text": "^0.2.9",
|
||||
"@bsky.app/peek-menu": "^0.3.1",
|
||||
"@bsky.app/react-native-mmkv": "2.12.5",
|
||||
"@bsky.app/sift": "^0.3.9",
|
||||
"@bsky.app/tapper": "^0.6.1",
|
||||
"@bsky.app/video": "0.3.6",
|
||||
@@ -158,7 +157,7 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"emoji-regex": "^10.4.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "54.0.34",
|
||||
"expo": "54.0.35",
|
||||
"expo-age-range": "0.2.18",
|
||||
"expo-application": "~7.0.8",
|
||||
"expo-asset": "~12.0.13",
|
||||
@@ -171,7 +170,7 @@
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-glass-effect": "55.0.8",
|
||||
"expo-glass-effect": "0.1.10",
|
||||
"expo-haptics": "~15.0.8",
|
||||
"expo-image": "~3.0.11",
|
||||
"expo-image-manipulator": "~14.0.8",
|
||||
@@ -185,7 +184,7 @@
|
||||
"expo-media-library": "~18.2.1",
|
||||
"expo-notifications": "~0.32.17",
|
||||
"expo-paste-input": "^0.2.1",
|
||||
"expo-privacy-sensitive": "^0.1.0",
|
||||
"expo-privacy-sensitive": "^0.2.0",
|
||||
"expo-screen-orientation": "~9.0.8",
|
||||
"expo-sharing": "~14.0.8",
|
||||
"expo-sms": "^14.0.7",
|
||||
@@ -227,21 +226,24 @@
|
||||
"react-native-device-attest": "^0.1.6",
|
||||
"react-native-drawer-layout": "^4.2.3",
|
||||
"react-native-edge-to-edge": "^1.8.1",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-keyboard-controller": "^1.21.8",
|
||||
"react-native-mmkv": "^3.3.3",
|
||||
"react-native-pager-view": "6.8.0",
|
||||
"react-native-progress": "bluesky-social/react-native-progress",
|
||||
"react-native-qrcode-styled": "^0.3.3",
|
||||
"react-native-reanimated": "3.19.1",
|
||||
"react-native-reanimated": "~4.3.2",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "4.24.0",
|
||||
"react-native-scroll-forwarder": "link:./modules/react-native-scroll-forwarder",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-uitextview": "^1.4.0",
|
||||
"react-native-uitextview": "^2.2.0",
|
||||
"react-native-uuid": "^2.0.3",
|
||||
"react-native-view-shot": "^4.0.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-web-webview": "^1.0.2",
|
||||
"react-native-webview": "^13.15.0",
|
||||
"react-native-worklets": "0.8.3",
|
||||
"react-remove-scroll-bar": "^2.3.8",
|
||||
"react-responsive": "^10.0.1",
|
||||
"react-textarea-autosize": "^8.5.3",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
diff --git a/ios/GlassContainer.swift b/ios/GlassContainer.swift
|
||||
index 61fb67cdfa2022f57524ddde05096067055e9ee6..b2d111ef8a724b8e7d4404f3d40efce3bd6fbb6d 100644
|
||||
--- a/ios/GlassContainer.swift
|
||||
+++ b/ios/GlassContainer.swift
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright 2022-present 650 Industries. All rights reserved.
|
||||
|
||||
import ExpoModulesCore
|
||||
+import React
|
||||
|
||||
public final class GlassContainer: ExpoView {
|
||||
private var containerEffect: Any?
|
||||
@@ -46,11 +47,19 @@ public final class GlassContainer: ExpoView {
|
||||
}
|
||||
}
|
||||
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the container effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ containerEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the container effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
containerEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
diff --git a/ios/GlassView.swift b/ios/GlassView.swift
|
||||
index 35cd8f320009a9e28fdbb2f55cc409734ba98f40..9587306b6fac3455ab27a5289eb62a711aa50c03 100644
|
||||
--- a/ios/GlassView.swift
|
||||
+++ b/ios/GlassView.swift
|
||||
@@ -271,11 +271,19 @@ public final class GlassView: ExpoView {
|
||||
#endif
|
||||
}
|
||||
}
|
||||
- public override func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ // Paper: redirect children into the glass effect's contentView
|
||||
+ public override func didUpdateReactSubviews() {
|
||||
+ for subview in self.reactSubviews() {
|
||||
+ glassEffectView.contentView.addSubview(subview)
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ // Fabric: redirect children into the glass effect's contentView
|
||||
+ @objc public func mountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
glassEffectView.contentView.insertSubview(childComponentView, at: index)
|
||||
}
|
||||
|
||||
- public override func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
+ @objc public func unmountChildComponentView(_ childComponentView: UIView, index: Int) {
|
||||
childComponentView.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# expo-glass-effect patch
|
||||
|
||||
Patches in support for Expo SDK 54. Please delete when we update Expo
|
||||
@@ -15,3 +15,17 @@ index 480746eb7acfbe86f67547d9e1de7a5be4d5faf2..13d30cb547195993dfcb4005cc0d248d
|
||||
|
||||
#endif
|
||||
-
|
||||
diff --git a/package.json b/package.json
|
||||
index 469386dc2e81ded8994818dd4340409da1396488..36460e4e303ae56534c4d389471fc827433c7028 100644
|
||||
--- a/package.json
|
||||
+++ b/package.json
|
||||
@@ -70,9 +70,6 @@
|
||||
"ios": {
|
||||
"componentProvider": {
|
||||
"RNDatePicker": "RNDatePicker"
|
||||
- },
|
||||
- "modulesProvider": {
|
||||
- "RNDatePicker": "RNDatePickerManager"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
diff --git a/apple/RNGestureHandler.mm b/apple/RNGestureHandler.mm
|
||||
index c4f760c41a9965245edcfa5e7cb781f8d2c7b66a..bf7d1fb092e22cbcedf787e606876e533879f810 100644
|
||||
--- a/apple/RNGestureHandler.mm
|
||||
+++ b/apple/RNGestureHandler.mm
|
||||
@@ -470,15 +470,19 @@ + (RNGestureHandler *)findGestureHandlerByRecognizer:(UIGestureRecognizer *)reco
|
||||
|
||||
// We may try to extract "DummyGestureHandler" in case when "otherGestureRecognizer" belongs to
|
||||
// a native view being wrapped with "NativeViewGestureHandler"
|
||||
- RNGHUIView *reactView = recognizer.view;
|
||||
- while (reactView != nil && reactView.reactTag == nil) {
|
||||
- reactView = reactView.superview;
|
||||
- }
|
||||
+ RNGHUIView *view = recognizer.view;
|
||||
+ while (view != nil) {
|
||||
+ for (UIGestureRecognizer *candidateRecognizer in view.gestureRecognizers) {
|
||||
+ if ([candidateRecognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
|
||||
+ return candidateRecognizer.gestureHandler;
|
||||
+ }
|
||||
+ }
|
||||
|
||||
- for (UIGestureRecognizer *recognizer in reactView.gestureRecognizers) {
|
||||
- if ([recognizer isKindOfClass:[RNDummyGestureRecognizer class]]) {
|
||||
- return recognizer.gestureHandler;
|
||||
+ if ([view isKindOfClass:[RCTViewComponentView class]]) {
|
||||
+ return nil;
|
||||
}
|
||||
+
|
||||
+ view = view.superview;
|
||||
}
|
||||
|
||||
return nil;
|
||||
@@ -0,0 +1,5 @@
|
||||
# react-native-gesture-handler.patch
|
||||
|
||||
Updated `findGestureHandlerByRecognizer:` in `apple/RNGestureHandler.mm` to the version from RN GH 2.32.0
|
||||
|
||||
This fixes `UIContextMenuInteraction` from `ExpoBlueskyPeekMenuView.swift`. https://github.com/software-mansion/react-native-gesture-handler/commit/fba4dcc06d71dce08b10b2afc738a2af5b01e86a
|
||||
@@ -1,3 +1,129 @@
|
||||
diff --git a/ios/Fabric/RNCPagerViewComponentView.mm b/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
index 652a5c123100e7010011f07649517aa9e0cbc554..efbc3af622c4fee8267a78144b906c7b5de7859c 100644
|
||||
--- a/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
+++ b/ios/Fabric/RNCPagerViewComponentView.mm
|
||||
@@ -90,6 +90,62 @@ - (void)willMoveToSuperview:(UIView *)newSuperview {
|
||||
}
|
||||
}
|
||||
|
||||
+/*
|
||||
+ * UIKit resolves several behaviors (status-bar-tap scroll-to-top, safe area
|
||||
+ * propagation, appearance callbacks) by walking parentViewController from a
|
||||
+ * view's nearest view controller up to the window's root view controller.
|
||||
+ * The Paper implementation embeds the UIPageViewController into that chain
|
||||
+ * via reactAddControllerToClosestParent:, but this Fabric implementation
|
||||
+ * leaves it orphaned (parentViewController == nil), which among other things
|
||||
+ * makes UIKit ignore every scroll view rendered inside the pager when
|
||||
+ * handling the status bar scroll-to-top tap. Attach the page view controller
|
||||
+ * to the nearest ancestor view controller to restore parity with Paper.
|
||||
+ */
|
||||
+- (void)attachNativePageViewControllerToNearestParent {
|
||||
+ if (_nativePageViewController == nil ||
|
||||
+ _nativePageViewController.parentViewController != nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ UIResponder *responder = self.nextResponder;
|
||||
+ while (responder != nil && ![responder isKindOfClass:[UIViewController class]]) {
|
||||
+ responder = responder.nextResponder;
|
||||
+ }
|
||||
+ UIViewController *parent = (UIViewController *)responder;
|
||||
+ if (parent == nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ [parent addChildViewController:_nativePageViewController];
|
||||
+ [_nativePageViewController didMoveToParentViewController:parent];
|
||||
+}
|
||||
+
|
||||
+- (void)detachNativePageViewControllerFromParent {
|
||||
+ if (_nativePageViewController.parentViewController == nil) {
|
||||
+ return;
|
||||
+ }
|
||||
+ [_nativePageViewController willMoveToParentViewController:nil];
|
||||
+ [_nativePageViewController removeFromParentViewController];
|
||||
+}
|
||||
+
|
||||
+- (void)didMoveToWindow {
|
||||
+ [super didMoveToWindow];
|
||||
+ if (self.window != nil) {
|
||||
+ [self attachNativePageViewControllerToNearestParent];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)layoutSubviews {
|
||||
+ [super layoutSubviews];
|
||||
+ /*
|
||||
+ * On the first didMoveToWindow the ancestor view controller may not be
|
||||
+ * wired up yet (see callstack/react-native-pager-view#1089 for the same
|
||||
+ * timing issue in v8), so retry here; the attach is a cheap no-op once
|
||||
+ * the controller has a parent.
|
||||
+ */
|
||||
+ if (self.window != nil) {
|
||||
+ [self attachNativePageViewControllerToNearestParent];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
|
||||
#pragma mark - React API
|
||||
|
||||
@@ -126,6 +182,13 @@ -(void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetrics
|
||||
|
||||
-(void)prepareForRecycle {
|
||||
[super prepareForRecycle];
|
||||
+ /*
|
||||
+ * Undo the child view controller relationship added in
|
||||
+ * attachNativePageViewControllerToNearestParent, otherwise the parent
|
||||
+ * view controller keeps the page view controller (and its subtree) alive
|
||||
+ * after unmount.
|
||||
+ */
|
||||
+ [self detachNativePageViewControllerFromParent];
|
||||
_nativePageViewController = nil;
|
||||
_currentIndex = -1;
|
||||
}
|
||||
@@ -421,8 +484,44 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
}
|
||||
|
||||
|
||||
+/*
|
||||
+ * Finds the navigation controller managing this pager via the responder
|
||||
+ * chain, so the pager can cooperate with the controller's back gesture.
|
||||
+ */
|
||||
+- (UINavigationController *)nearestNavigationController {
|
||||
+ UIResponder *responder = self.nextResponder;
|
||||
+ while (responder != nil) {
|
||||
+ if ([responder isKindOfClass:[UINavigationController class]]) {
|
||||
+ return (UINavigationController *)responder;
|
||||
+ }
|
||||
+ responder = responder.nextResponder;
|
||||
+ }
|
||||
+ return nil;
|
||||
+}
|
||||
+
|
||||
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
|
||||
|
||||
+ // iOS 26+ full-screen back gesture (interactiveContentPopGestureRecognizer)
|
||||
+ if (@available(iOS 26.0, *)) {
|
||||
+ if (gestureRecognizer == self.panGestureRecognizer &&
|
||||
+ otherGestureRecognizer != nil &&
|
||||
+ otherGestureRecognizer == [self nearestNavigationController].interactiveContentPopGestureRecognizer) {
|
||||
+ UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
+ CGPoint velocity = [panGestureRecognizer velocityInView:self];
|
||||
+ BOOL isLTR = [self isLtrLayout];
|
||||
+ BOOL isBackGesture = (isLTR && velocity.x > 0) || (!isLTR && velocity.x < 0);
|
||||
+
|
||||
+ if (self.currentIndex == 0 && isBackGesture) {
|
||||
+ scrollView.panGestureRecognizer.enabled = false;
|
||||
+ } else {
|
||||
+ const auto &viewProps = *std::static_pointer_cast<const RNCViewPagerProps>(_props);
|
||||
+ scrollView.panGestureRecognizer.enabled = viewProps.scrollEnabled;
|
||||
+ }
|
||||
+
|
||||
+ return YES;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// Recognize simultaneously only if the other gesture is RN Screen's pan gesture (one that is used to perform fullScreenGestureEnabled)
|
||||
if (gestureRecognizer == self.panGestureRecognizer && [NSStringFromClass([otherGestureRecognizer class]) isEqual: @"RNSPanGestureRecognizer"]) {
|
||||
UIPanGestureRecognizer* panGestureRecognizer = (UIPanGestureRecognizer*) gestureRecognizer;
|
||||
diff --git a/ios/RNCPagerView.m b/ios/RNCPagerView.m
|
||||
index adfc7c6f2224b898a02319d352bb4fe11a18fd7e..939bb801c5b0ca6f93b77cb0507c19d137e08e77 100644
|
||||
--- a/ios/RNCPagerView.m
|
||||
|
||||
@@ -6,6 +6,20 @@ The pager already handles `RNSPanGestureRecognizer` (react-native-screens' custo
|
||||
|
||||
This patch adds the same logic for iOS 26's native `interactiveContentPopGestureRecognizer`, so the back gesture works on the leftmost page while the pager still handles swipes on other pages.
|
||||
|
||||
The fix is applied to both implementations: `ios/RNCPagerView.m` (Paper) and `ios/Fabric/RNCPagerViewComponentView.mm` (New Architecture). The Fabric variant finds the navigation controller via the responder chain (there is no `reactViewController` helper imported there) and reads `scrollEnabled` from the Fabric props.
|
||||
|
||||
Related issues:
|
||||
- https://github.com/software-mansion/react-native-screens/issues/3512
|
||||
- https://github.com/software-mansion/react-native-screens/pull/3420
|
||||
|
||||
---
|
||||
|
||||
Also embeds the Fabric `UIPageViewController` into the view controller hierarchy (`ios/Fabric/RNCPagerViewComponentView.mm`).
|
||||
|
||||
The Paper implementation calls `reactAddControllerToClosestParent:` when embedding its `UIPageViewController`, so the controller becomes a child of the nearest ancestor view controller (e.g. `RNSScreen`). The Fabric implementation never does this - the page view controller is orphaned (`parentViewController == nil`).
|
||||
|
||||
UIKit resolves the status-bar-tap scroll-to-top gesture by walking `parentViewController`/`presentingViewController` from each candidate scroll view's nearest view controller up to the window's root (see `-[UIWindow _scrollToTopViewsUnderScreenPointIfNecessary:resultHandler:]`). With the orphaned controller that walk dead-ends, so every scroll view rendered inside a pager (all Home feeds, Profile tabs, etc.) is dropped from candidate selection and tapping the status bar no longer scrolls feeds to top. It only kept "working" when the window happened to contain exactly one other eligible scroll view, via UIKit's single-candidate fallback.
|
||||
|
||||
The patch attaches the page view controller to the nearest view controller found via the responder chain on `didMoveToWindow` (with a `layoutSubviews` retry because the ancestor controller may not be wired up on the first pass - same timing issue as callstack/react-native-pager-view#1089), and detaches it in `prepareForRecycle` to avoid leaking the controller after unmount.
|
||||
|
||||
Fixed upstream in v8 by the SwiftUI rewrite, which embeds via `reactViewController()` + `addChild` (see `PagerViewProvider.swift`).
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
diff --git a/lib/module/component/PerformanceMonitor.js b/lib/module/component/PerformanceMonitor.js
|
||||
index 9c98d6cc395419f50969753a4e4c7962b7be588f..3686a97280ac540efa0814ac4dea40358860a76f 100644
|
||||
--- a/lib/module/component/PerformanceMonitor.js
|
||||
+++ b/lib/module/component/PerformanceMonitor.js
|
||||
@@ -1,125 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
-import React, { useEffect, useRef } from 'react';
|
||||
-import { StyleSheet, TextInput, View } from 'react-native';
|
||||
-import { addWhitelistedNativeProps } from "../ConfigHelper.js";
|
||||
-import { createAnimatedComponent } from "../createAnimatedComponent/index.js";
|
||||
-import { useAnimatedProps, useFrameCallback, useSharedValue } from "../hook/index.js";
|
||||
-function createCircularDoublesBuffer(size) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return {
|
||||
- next: 0,
|
||||
- buffer: new Float32Array(size),
|
||||
- size,
|
||||
- count: 0,
|
||||
- push(value) {
|
||||
- const oldValue = this.buffer[this.next];
|
||||
- const oldCount = this.count;
|
||||
- this.buffer[this.next] = value;
|
||||
- this.next = (this.next + 1) % this.size;
|
||||
- this.count = Math.min(this.size, this.count + 1);
|
||||
- return oldCount === this.size ? oldValue : null;
|
||||
- },
|
||||
- front() {
|
||||
- const notEmpty = this.count > 0;
|
||||
- if (notEmpty) {
|
||||
- const current = this.next - 1;
|
||||
- const index = current < 0 ? this.size - 1 : current;
|
||||
- return this.buffer[index];
|
||||
- }
|
||||
- return null;
|
||||
- },
|
||||
- back() {
|
||||
- const notEmpty = this.count > 0;
|
||||
- return notEmpty ? this.buffer[this.next] : null;
|
||||
- }
|
||||
- };
|
||||
-}
|
||||
-const DEFAULT_BUFFER_SIZE = 20;
|
||||
-addWhitelistedNativeProps({
|
||||
- text: true
|
||||
-});
|
||||
-const AnimatedTextInput = createAnimatedComponent(TextInput);
|
||||
-function loopAnimationFrame(fn) {
|
||||
- let lastTime = 0;
|
||||
- function loop() {
|
||||
- requestAnimationFrame(time => {
|
||||
- if (lastTime > 0) {
|
||||
- fn(lastTime, time);
|
||||
- }
|
||||
- lastTime = time;
|
||||
- requestAnimationFrame(loop);
|
||||
- });
|
||||
- }
|
||||
- loop();
|
||||
-}
|
||||
-function getFps(renderTimeInMs) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return 1000 / renderTimeInMs;
|
||||
-}
|
||||
-function completeBufferRoutine(buffer, timestamp) {
|
||||
- 'worklet';
|
||||
-
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const droppedTimestamp = buffer.push(timestamp) ?? timestamp;
|
||||
- const measuredRangeDuration = timestamp - droppedTimestamp;
|
||||
- return getFps(measuredRangeDuration / buffer.count);
|
||||
-}
|
||||
-function JsPerformance({
|
||||
- smoothingFrames
|
||||
-}) {
|
||||
- const jsFps = useSharedValue(null);
|
||||
- const totalRenderTime = useSharedValue(0);
|
||||
- const circularBuffer = useRef(createCircularDoublesBuffer(smoothingFrames));
|
||||
- useEffect(() => {
|
||||
- loopAnimationFrame((_, timestamp) => {
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.current, timestamp);
|
||||
-
|
||||
- // JS fps have to be measured every 2nd frame,
|
||||
- // thus 2x multiplication has to occur here
|
||||
- jsFps.value = (currentFps * 2).toFixed(0);
|
||||
- });
|
||||
- }, [jsFps, totalRenderTime]);
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'JS: ' + (jsFps.value ?? 'N/A') + ' ';
|
||||
- return {
|
||||
- text,
|
||||
- defaultValue: text
|
||||
- };
|
||||
- });
|
||||
- return <View style={styles.container}>
|
||||
- <AnimatedTextInput style={styles.text} animatedProps={animatedProps} editable={false} />
|
||||
- </View>;
|
||||
-}
|
||||
-function UiPerformance({
|
||||
- smoothingFrames
|
||||
-}) {
|
||||
- const uiFps = useSharedValue(null);
|
||||
- const circularBuffer = useSharedValue(null);
|
||||
- useFrameCallback(({
|
||||
- timestamp
|
||||
- }) => {
|
||||
- if (circularBuffer.value === null) {
|
||||
- circularBuffer.value = createCircularDoublesBuffer(smoothingFrames);
|
||||
- }
|
||||
- timestamp = Math.round(timestamp);
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.value, timestamp);
|
||||
- uiFps.value = currentFps.toFixed(0);
|
||||
- });
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'UI: ' + (uiFps.value ?? 'N/A') + ' ';
|
||||
- return {
|
||||
- text,
|
||||
- defaultValue: text
|
||||
- };
|
||||
- });
|
||||
- return <View style={styles.container}>
|
||||
- <AnimatedTextInput style={styles.text} animatedProps={animatedProps} editable={false} />
|
||||
- </View>;
|
||||
-}
|
||||
/**
|
||||
* A component that lets you measure fps values on JS and UI threads on both the
|
||||
* Paper and Fabric architectures.
|
||||
@@ -127,38 +7,7 @@ function UiPerformance({
|
||||
* @param smoothingFrames - Determines amount of saved frames which will be used
|
||||
* for fps value smoothing.
|
||||
*/
|
||||
-export function PerformanceMonitor({
|
||||
- smoothingFrames = DEFAULT_BUFFER_SIZE
|
||||
-}) {
|
||||
- return <View style={styles.monitor}>
|
||||
- <JsPerformance smoothingFrames={smoothingFrames} />
|
||||
- <UiPerformance smoothingFrames={smoothingFrames} />
|
||||
- </View>;
|
||||
+export function PerformanceMonitor() {
|
||||
+ return null;
|
||||
}
|
||||
-const styles = StyleSheet.create({
|
||||
- monitor: {
|
||||
- flexDirection: 'row',
|
||||
- position: 'absolute',
|
||||
- backgroundColor: '#0006',
|
||||
- zIndex: 1000
|
||||
- },
|
||||
- header: {
|
||||
- fontSize: 14,
|
||||
- color: '#ffff',
|
||||
- paddingHorizontal: 5
|
||||
- },
|
||||
- text: {
|
||||
- fontSize: 13,
|
||||
- fontVariant: ['tabular-nums'],
|
||||
- color: '#ffff',
|
||||
- fontFamily: 'monospace',
|
||||
- paddingHorizontal: 3
|
||||
- },
|
||||
- container: {
|
||||
- alignItems: 'center',
|
||||
- justifyContent: 'center',
|
||||
- flexDirection: 'row',
|
||||
- flexWrap: 'wrap'
|
||||
- }
|
||||
-});
|
||||
//# sourceMappingURL=PerformanceMonitor.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/src/component/PerformanceMonitor.tsx b/src/component/PerformanceMonitor.tsx
|
||||
index ff8fc8a947a0aeb959e21ec061882c3d190a2ce0..34dde79727765623df80dbcdab5016de6fd5d82c 100644
|
||||
--- a/src/component/PerformanceMonitor.tsx
|
||||
+++ b/src/component/PerformanceMonitor.tsx
|
||||
@@ -1,170 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
-import React, { useEffect, useRef } from 'react';
|
||||
-import { StyleSheet, TextInput, View } from 'react-native';
|
||||
-
|
||||
-import { addWhitelistedNativeProps } from '../ConfigHelper';
|
||||
-import { createAnimatedComponent } from '../createAnimatedComponent';
|
||||
-import type { FrameInfo } from '../frameCallback';
|
||||
-import { useAnimatedProps, useFrameCallback, useSharedValue } from '../hook';
|
||||
-
|
||||
-type CircularBuffer = ReturnType<typeof createCircularDoublesBuffer>;
|
||||
-function createCircularDoublesBuffer(size: number) {
|
||||
- 'worklet';
|
||||
-
|
||||
- return {
|
||||
- next: 0 as number,
|
||||
- buffer: new Float32Array(size),
|
||||
- size,
|
||||
- count: 0 as number,
|
||||
-
|
||||
- push(value: number): number | null {
|
||||
- const oldValue = this.buffer[this.next];
|
||||
- const oldCount = this.count;
|
||||
- this.buffer[this.next] = value;
|
||||
-
|
||||
- this.next = (this.next + 1) % this.size;
|
||||
- this.count = Math.min(this.size, this.count + 1);
|
||||
- return oldCount === this.size ? oldValue : null;
|
||||
- },
|
||||
-
|
||||
- front(): number | null {
|
||||
- const notEmpty = this.count > 0;
|
||||
- if (notEmpty) {
|
||||
- const current = this.next - 1;
|
||||
- const index = current < 0 ? this.size - 1 : current;
|
||||
- return this.buffer[index];
|
||||
- }
|
||||
- return null;
|
||||
- },
|
||||
-
|
||||
- back(): number | null {
|
||||
- const notEmpty = this.count > 0;
|
||||
- return notEmpty ? this.buffer[this.next] : null;
|
||||
- },
|
||||
- };
|
||||
-}
|
||||
-
|
||||
-const DEFAULT_BUFFER_SIZE = 20;
|
||||
-addWhitelistedNativeProps({ text: true });
|
||||
-const AnimatedTextInput = createAnimatedComponent(TextInput);
|
||||
-
|
||||
-function loopAnimationFrame(fn: (lastTime: number, time: number) => void) {
|
||||
- let lastTime = 0;
|
||||
-
|
||||
- function loop() {
|
||||
- requestAnimationFrame((time) => {
|
||||
- if (lastTime > 0) {
|
||||
- fn(lastTime, time);
|
||||
- }
|
||||
- lastTime = time;
|
||||
- requestAnimationFrame(loop);
|
||||
- });
|
||||
- }
|
||||
-
|
||||
- loop();
|
||||
-}
|
||||
-
|
||||
-function getFps(renderTimeInMs: number): number {
|
||||
- 'worklet';
|
||||
- return 1000 / renderTimeInMs;
|
||||
-}
|
||||
-
|
||||
-function completeBufferRoutine(
|
||||
- buffer: CircularBuffer,
|
||||
- timestamp: number
|
||||
-): number {
|
||||
- 'worklet';
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const droppedTimestamp = buffer.push(timestamp) ?? timestamp;
|
||||
-
|
||||
- const measuredRangeDuration = timestamp - droppedTimestamp;
|
||||
-
|
||||
- return getFps(measuredRangeDuration / buffer.count);
|
||||
-}
|
||||
-
|
||||
-function JsPerformance({ smoothingFrames }: { smoothingFrames: number }) {
|
||||
- const jsFps = useSharedValue<string | null>(null);
|
||||
- const totalRenderTime = useSharedValue(0);
|
||||
- const circularBuffer = useRef<CircularBuffer>(
|
||||
- createCircularDoublesBuffer(smoothingFrames)
|
||||
- );
|
||||
-
|
||||
- useEffect(() => {
|
||||
- loopAnimationFrame((_, timestamp) => {
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const currentFps = completeBufferRoutine(
|
||||
- circularBuffer.current,
|
||||
- timestamp
|
||||
- );
|
||||
-
|
||||
- // JS fps have to be measured every 2nd frame,
|
||||
- // thus 2x multiplication has to occur here
|
||||
- jsFps.value = (currentFps * 2).toFixed(0);
|
||||
- });
|
||||
- }, [jsFps, totalRenderTime]);
|
||||
-
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'JS: ' + (jsFps.value ?? 'N/A') + ' ';
|
||||
- return { text, defaultValue: text };
|
||||
- });
|
||||
-
|
||||
- return (
|
||||
- <View style={styles.container}>
|
||||
- <AnimatedTextInput
|
||||
- style={styles.text}
|
||||
- animatedProps={animatedProps}
|
||||
- editable={false}
|
||||
- />
|
||||
- </View>
|
||||
- );
|
||||
-}
|
||||
-
|
||||
-function UiPerformance({ smoothingFrames }: { smoothingFrames: number }) {
|
||||
- const uiFps = useSharedValue<string | null>(null);
|
||||
- const circularBuffer = useSharedValue<CircularBuffer | null>(null);
|
||||
-
|
||||
- useFrameCallback(({ timestamp }: FrameInfo) => {
|
||||
- if (circularBuffer.value === null) {
|
||||
- circularBuffer.value = createCircularDoublesBuffer(smoothingFrames);
|
||||
- }
|
||||
-
|
||||
- timestamp = Math.round(timestamp);
|
||||
-
|
||||
- const currentFps = completeBufferRoutine(circularBuffer.value, timestamp);
|
||||
-
|
||||
- uiFps.value = currentFps.toFixed(0);
|
||||
- });
|
||||
-
|
||||
- const animatedProps = useAnimatedProps(() => {
|
||||
- const text = 'UI: ' + (uiFps.value ?? 'N/A') + ' ';
|
||||
- return { text, defaultValue: text };
|
||||
- });
|
||||
-
|
||||
- return (
|
||||
- <View style={styles.container}>
|
||||
- <AnimatedTextInput
|
||||
- style={styles.text}
|
||||
- animatedProps={animatedProps}
|
||||
- editable={false}
|
||||
- />
|
||||
- </View>
|
||||
- );
|
||||
-}
|
||||
-
|
||||
-export type PerformanceMonitorProps = {
|
||||
- /**
|
||||
- * Sets amount of previous frames used for smoothing at highest expectedFps.
|
||||
- *
|
||||
- * Automatically scales down at lower frame rates.
|
||||
- *
|
||||
- * Affects jumpiness of the FPS measurements value.
|
||||
- */
|
||||
- smoothingFrames?: number;
|
||||
-};
|
||||
-
|
||||
/**
|
||||
* A component that lets you measure fps values on JS and UI threads on both the
|
||||
* Paper and Fabric architectures.
|
||||
@@ -172,40 +7,6 @@ export type PerformanceMonitorProps = {
|
||||
* @param smoothingFrames - Determines amount of saved frames which will be used
|
||||
* for fps value smoothing.
|
||||
*/
|
||||
-export function PerformanceMonitor({
|
||||
- smoothingFrames = DEFAULT_BUFFER_SIZE,
|
||||
-}: PerformanceMonitorProps) {
|
||||
- return (
|
||||
- <View style={styles.monitor}>
|
||||
- <JsPerformance smoothingFrames={smoothingFrames} />
|
||||
- <UiPerformance smoothingFrames={smoothingFrames} />
|
||||
- </View>
|
||||
- );
|
||||
+export function PerformanceMonitor() {
|
||||
+ return null;
|
||||
}
|
||||
-
|
||||
-const styles = StyleSheet.create({
|
||||
- monitor: {
|
||||
- flexDirection: 'row',
|
||||
- position: 'absolute',
|
||||
- backgroundColor: '#0006',
|
||||
- zIndex: 1000,
|
||||
- },
|
||||
- header: {
|
||||
- fontSize: 14,
|
||||
- color: '#ffff',
|
||||
- paddingHorizontal: 5,
|
||||
- },
|
||||
- text: {
|
||||
- fontSize: 13,
|
||||
- fontVariant: ['tabular-nums'],
|
||||
- color: '#ffff',
|
||||
- fontFamily: 'monospace',
|
||||
- paddingHorizontal: 3,
|
||||
- },
|
||||
- container: {
|
||||
- alignItems: 'center',
|
||||
- justifyContent: 'center',
|
||||
- flexDirection: 'row',
|
||||
- flexWrap: 'wrap',
|
||||
- },
|
||||
-});
|
||||
@@ -0,0 +1,500 @@
|
||||
diff --git a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
index 531f0dc7b4eeb9b29cb2255d8444da02a74c35b7..534f419fce55c39a09a7eebfb7ab3c53f8a16637 100644
|
||||
--- a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
+++ b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.cpp
|
||||
@@ -1,8 +1,10 @@
|
||||
#include <reanimated/Fabric/updates/AnimatedPropsRegistry.h>
|
||||
#include <reanimated/Tools/FeatureFlags.h>
|
||||
|
||||
+#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
+#include <vector>
|
||||
|
||||
namespace reanimated {
|
||||
|
||||
@@ -25,25 +27,59 @@ void AnimatedPropsRegistry::update(jsi::Runtime &rt, const jsi::Value &operation
|
||||
addUpdatesToBatch(shadowNode, jsi::dynamicFromValue(rt, updates));
|
||||
|
||||
if constexpr (StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS")) {
|
||||
- timestampMap_[shadowNode->getTag()] = timestamp;
|
||||
+ const auto tag = shadowNode->getTag();
|
||||
+ timestampMap_[tag] = timestamp;
|
||||
+ // If JS already has a `settledProps` snapshot for this tag, it is now
|
||||
+ // stale — schedule a refresh on the next `collectSettledUpdates`.
|
||||
+ if (syncedTags_.erase(tag) > 0) {
|
||||
+ invalidatedTags_.insert(tag);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
|
||||
- jsi::Runtime &rt,
|
||||
- const double timestamp,
|
||||
- const double cleanupTimestamp) {
|
||||
+jsi::Value AnimatedPropsRegistry::collectSettledUpdates(jsi::Runtime &rt, const double settledTimestamp) {
|
||||
std::lock_guard<std::mutex> lock{mutex_};
|
||||
- removeUpdatesOlderThanTimestamp(cleanupTimestamp);
|
||||
|
||||
std::vector<std::pair<Tag, std::reference_wrapper<const folly::dynamic>>> updates;
|
||||
|
||||
- for (const auto &[viewTag, pair] : updatesRegistry_) {
|
||||
- auto it = timestampMap_.find(viewTag);
|
||||
- if (it != timestampMap_.end() && it->second < timestamp) {
|
||||
- updates.emplace_back(viewTag, std::cref(pair.second));
|
||||
+ for (auto it = updatesRegistry_.begin(); it != updatesRegistry_.end();) {
|
||||
+ const auto viewTag = it->first;
|
||||
+
|
||||
+ if (syncedTags_.contains(viewTag)) {
|
||||
+ // React already has the latest value for this tag (synced on a previous
|
||||
+ // call, so the `settledProps` state is committed by now) — the registry
|
||||
+ // entry is redundant. `syncedTags_` is intentionally retained to detect
|
||||
+ // re-animation staleness. Note that `syncedTags_` and `invalidatedTags_`
|
||||
+ // are disjoint — `update()` moves tags from the former to the latter.
|
||||
+ timestampMap_.erase(viewTag);
|
||||
+ it = updatesRegistry_.erase(it);
|
||||
+ continue;
|
||||
+ }
|
||||
+
|
||||
+ const auto timestampIt = timestampMap_.find(viewTag);
|
||||
+ if (timestampIt == timestampMap_.end()) {
|
||||
+ ++it;
|
||||
+ continue;
|
||||
+ }
|
||||
+ const bool isSettled = timestampIt->second < settledTimestamp;
|
||||
+ const auto invalidatedIt = invalidatedTags_.find(viewTag);
|
||||
+ const bool isInvalidated = invalidatedIt != invalidatedTags_.end();
|
||||
+ if (isSettled || isInvalidated) {
|
||||
+ updates.emplace_back(viewTag, std::cref(it->second.second));
|
||||
+ if (isSettled) {
|
||||
+ // Only settled-path tags are tracked as "synced" so that an ongoing
|
||||
+ // animation doesn't re-trigger an invalidation/sync on every GC tick.
|
||||
+ syncedTags_.insert(viewTag);
|
||||
+ }
|
||||
+ if (isInvalidated) {
|
||||
+ // Only erase serviced invalidations; if a tag was invalidated but the
|
||||
+ // matching update batch hasn't been flushed into updatesRegistry_ yet,
|
||||
+ // we leave the entry so the next sync picks it up.
|
||||
+ invalidatedTags_.erase(invalidatedIt);
|
||||
+ }
|
||||
}
|
||||
+ ++it;
|
||||
}
|
||||
|
||||
const jsi::Array array(rt, updates.size());
|
||||
@@ -58,22 +94,11 @@ jsi::Value AnimatedPropsRegistry::getUpdatesOlderThanTimestamp(
|
||||
return jsi::Value(rt, array);
|
||||
}
|
||||
|
||||
-void AnimatedPropsRegistry::removeUpdatesOlderThanTimestamp(const double timestamp) {
|
||||
- for (auto it = timestampMap_.begin(); it != timestampMap_.end();) {
|
||||
- const auto viewTag = it->first;
|
||||
- const auto viewTimestamp = it->second;
|
||||
- if (viewTimestamp < timestamp) {
|
||||
- it = timestampMap_.erase(it);
|
||||
- updatesRegistry_.erase(viewTag);
|
||||
- } else {
|
||||
- it++;
|
||||
- }
|
||||
- }
|
||||
-}
|
||||
-
|
||||
void AnimatedPropsRegistry::removeTag(const Tag tag) {
|
||||
updatesRegistry_.erase(tag);
|
||||
timestampMap_.erase(tag);
|
||||
+ syncedTags_.erase(tag);
|
||||
+ invalidatedTags_.erase(tag);
|
||||
}
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
index 2c6c0e13604c9421e147d7eea7f4a4752288011c..8cd67f118501c2786b94d76541aea29a14ba8c16 100644
|
||||
--- a/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
+++ b/Common/cpp/reanimated/Fabric/updates/AnimatedPropsRegistry.h
|
||||
@@ -4,10 +4,8 @@
|
||||
|
||||
#include <react/renderer/uimanager/UIManager.h>
|
||||
|
||||
-#include <memory>
|
||||
-#include <string>
|
||||
#include <unordered_map>
|
||||
-#include <vector>
|
||||
+#include <unordered_set>
|
||||
|
||||
namespace reanimated {
|
||||
|
||||
@@ -15,13 +13,22 @@ class AnimatedPropsRegistry : public UpdatesRegistry {
|
||||
public:
|
||||
void update(jsi::Runtime &rt, const jsi::Value &operations, double timestamp);
|
||||
|
||||
- /// Also removes updates older than `cleanupTimestamp` from the registry.
|
||||
- jsi::Value getUpdatesOlderThanTimestamp(jsi::Runtime &rt, double timestamp, double cleanupTimestamp);
|
||||
+ /// Returns updates that settled (received no update since `settledTimestamp`)
|
||||
+ /// or whose synced `settledProps` snapshot was invalidated by a fresh update.
|
||||
+ /// Also evicts entries that have already been synced to React — by the time
|
||||
+ /// of the next call, the corresponding `settledProps` state is guaranteed to
|
||||
+ /// be committed, so the registry entries are redundant.
|
||||
+ jsi::Value collectSettledUpdates(jsi::Runtime &rt, double settledTimestamp);
|
||||
|
||||
private:
|
||||
std::unordered_map<Tag, double> timestampMap_; // viewTag -> timestamp, protected by `mutex_`
|
||||
+ // Tags whose latest values have already been pushed to React `settledProps`.
|
||||
+ // Intentionally retained after eviction to detect re-animation staleness.
|
||||
+ std::unordered_set<Tag> syncedTags_;
|
||||
+ // Tags that were synced to React but received a fresh worklet update since;
|
||||
+ // their `settledProps` are stale and need to be refreshed on the next sync.
|
||||
+ std::unordered_set<Tag> invalidatedTags_;
|
||||
|
||||
- void removeUpdatesOlderThanTimestamp(double timestamp);
|
||||
void removeTag(Tag tag) override;
|
||||
};
|
||||
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
index 096218ab9659955cd6272c97181bce3c893ed591..1a8e25fc8295b3ac943130709bf063ea41a50585 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h
|
||||
@@ -57,11 +57,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<facebook::react::UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<facebook::react::UIManager> &uiManager,
|
||||
const std::shared_ptr<facebook::react::CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -69,11 +69,11 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
contextContainer_(contextContainer),
|
||||
componentDescriptorRegistry_(componentDescriptorRegistry),
|
||||
uiRuntime_(uiRuntime),
|
||||
- uiScheduler_(uiScheduler)
|
||||
+ uiScheduler_(uiScheduler),
|
||||
+ uiManager_(uiManager)
|
||||
#ifdef ANDROID
|
||||
,
|
||||
preserveMountedTags_(filterUnmountedTagsFunction),
|
||||
- uiManager_(uiManager),
|
||||
jsInvoker_(jsInvoker)
|
||||
#endif
|
||||
{
|
||||
@@ -93,10 +93,10 @@ class LayoutAnimationsProxyCommon : public facebook::react::MountingOverrideDele
|
||||
SharedComponentDescriptorRegistry componentDescriptorRegistry_;
|
||||
jsi::Runtime &uiRuntime_;
|
||||
const std::shared_ptr<UIScheduler> uiScheduler_;
|
||||
+ std::shared_ptr<facebook::react::UIManager> uiManager_;
|
||||
PreserveMountedTagsFunction preserveMountedTags_;
|
||||
|
||||
#ifdef ANDROID
|
||||
- std::shared_ptr<facebook::react::UIManager> uiManager_;
|
||||
std::shared_ptr<facebook::react::CallInvoker> jsInvoker_;
|
||||
|
||||
void restoreOpacityInCaseOfFlakyEnteringAnimation(SurfaceId surfaceId) const;
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
index eca44e4cf651d16e9741806004ce9119c85d07d6..e39c79a5d7b52659106ed6fe6fbcbbc048bf4787 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Experimental.h
|
||||
@@ -66,11 +66,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<UIManager> &uiManager,
|
||||
const std::shared_ptr<CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -79,11 +79,11 @@ struct LayoutAnimationsProxy_Experimental : public LayoutAnimationsProxyCommon,
|
||||
componentDescriptorRegistry,
|
||||
contextContainer,
|
||||
uiRuntime,
|
||||
- uiScheduler
|
||||
+ uiScheduler,
|
||||
+ uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction,
|
||||
- uiManager,
|
||||
jsInvoker
|
||||
#endif
|
||||
),
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
index 83ef7430b923b6b3b99368ee8072168769110cd0..2affd12822ab19bdc90963d3ce8ca1e6bb0d43b7 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.cpp
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <reanimated/NativeModules/ReanimatedModuleProxy.h>
|
||||
|
||||
#include <react/renderer/animations/utils.h>
|
||||
+#include <react/renderer/mounting/ShadowTree.h>
|
||||
#include <react/renderer/mounting/ShadowViewMutation.h>
|
||||
|
||||
#include <memory>
|
||||
@@ -53,14 +54,37 @@ std::optional<MountingTransaction> LayoutAnimationsProxy_Legacy::pullTransaction
|
||||
|
||||
parseRemoveMutations(movedViews, mutations, roots);
|
||||
|
||||
- auto shouldAnimate = !surfacesToRemove_.contains(surfaceId);
|
||||
- surfacesToRemove_.erase(surfaceId);
|
||||
+ // Consume the teardown mark only on the transaction that actually clears
|
||||
+ // the root — pulls emitted for animation frames must not eat it early.
|
||||
+ auto shouldAnimate = true;
|
||||
+ const auto removesRootChildren = std::ranges::any_of(mutations, [surfaceId](const auto &mutation) {
|
||||
+ return mutation.type == ShadowViewMutation::Remove && mutation.parentTag == surfaceId;
|
||||
+ });
|
||||
+ if (removesRootChildren) {
|
||||
+ shouldAnimate = surfacesToRemove_.erase(surfaceId) == 0;
|
||||
+ }
|
||||
handleRemovals(filteredMutations, roots, deadNodes, shouldAnimate);
|
||||
|
||||
handleUpdatesAndEnterings(filteredMutations, movedViews, mutations, propsParserContext, surfaceId);
|
||||
|
||||
addOngoingAnimations(surfaceId, filteredMutations);
|
||||
|
||||
+ // The LayoutAnimationDriver can emit a final keyframe update in the same
|
||||
+ // transaction as the deferred Remove/Delete it withheld for a delete
|
||||
+ // animation. We emit removals before updates, so such an update would
|
||||
+ // otherwise reach the mounting layer after its view was deleted.
|
||||
+ std::unordered_set<Tag> deletedTags;
|
||||
+ for (const auto &mutation : filteredMutations) {
|
||||
+ if (mutation.type == ShadowViewMutation::Delete) {
|
||||
+ deletedTags.insert(mutation.oldChildShadowView.tag);
|
||||
+ }
|
||||
+ }
|
||||
+ if (!deletedTags.empty()) {
|
||||
+ std::erase_if(filteredMutations, [&deletedTags](const auto &mutation) {
|
||||
+ return mutation.type == ShadowViewMutation::Update && deletedTags.contains(mutation.newChildShadowView.tag);
|
||||
+ });
|
||||
+ }
|
||||
+
|
||||
return MountingTransaction{surfaceId, transactionNumber, std::move(filteredMutations), telemetry};
|
||||
}
|
||||
|
||||
@@ -947,23 +971,22 @@ inline bool MutationNode::isMutationNode() {
|
||||
return true;
|
||||
}
|
||||
|
||||
-// UIManagerAnimationDelegate
|
||||
-
|
||||
-void LayoutAnimationsProxy_Legacy::uiManagerDidConfigureNextLayoutAnimation(
|
||||
- jsi::Runtime &runtime,
|
||||
- const RawValue &config,
|
||||
- const jsi::Value &successCallbackValue,
|
||||
- const jsi::Value &failureCallbackValue) const {}
|
||||
+// UIManagerCommitHook
|
||||
|
||||
-void LayoutAnimationsProxy_Legacy::setComponentDescriptorRegistry(
|
||||
- const SharedComponentDescriptorRegistry &componentDescriptorRegistry) {}
|
||||
-
|
||||
-bool LayoutAnimationsProxy_Legacy::shouldAnimateFrame() const {
|
||||
- return false;
|
||||
-}
|
||||
-
|
||||
-void LayoutAnimationsProxy_Legacy::stopSurface(SurfaceId surfaceId) {
|
||||
- surfacesToRemove_.insert(surfaceId);
|
||||
+// Surface teardown commits an empty root (SurfaceHandler::stop) before the
|
||||
+// teardown transaction is pulled — mark it so pullTransaction skips exit
|
||||
+// animations. Reading the ShadowTreeRegistry here instead would deadlock (#8579).
|
||||
+RootShadowNode::Unshared LayoutAnimationsProxy_Legacy::shadowTreeWillCommit(
|
||||
+ const ShadowTree &shadowTree,
|
||||
+ const RootShadowNode::Shared & /*oldRootShadowNode*/,
|
||||
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept {
|
||||
+ auto lock = std::unique_lock<std::recursive_mutex>(mutex);
|
||||
+ if (newRootShadowNode->getChildren().empty()) {
|
||||
+ surfacesToRemove_.insert(shadowTree.getSurfaceId());
|
||||
+ } else {
|
||||
+ surfacesToRemove_.erase(shadowTree.getSurfaceId());
|
||||
+ }
|
||||
+ return newRootShadowNode;
|
||||
}
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
index e9a5e9959e89ec33cee179ddb907c17f6dfbd3de..a2c89041518cd71e8ba5ac62ef89c0022d197c9b 100644
|
||||
--- a/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
+++ b/Common/cpp/reanimated/LayoutAnimations/LayoutAnimationsProxy_Legacy.h
|
||||
@@ -3,8 +3,8 @@
|
||||
#include <react/renderer/componentregistry/ComponentDescriptorFactory.h>
|
||||
#include <react/renderer/mounting/MountingOverrideDelegate.h>
|
||||
#include <react/renderer/scheduler/Scheduler.h>
|
||||
-#include <react/renderer/uimanager/UIManagerAnimationDelegate.h>
|
||||
#include <react/renderer/uimanager/UIManagerBinding.h>
|
||||
+#include <react/renderer/uimanager/UIManagerCommitHook.h>
|
||||
#include <reanimated/Compat/WorkletsApi.h>
|
||||
#include <reanimated/LayoutAnimations/LayoutAnimationsManager.h>
|
||||
#include <reanimated/LayoutAnimations/LayoutAnimationsProxyCommon.h>
|
||||
@@ -102,7 +102,7 @@ struct SurfaceContext {
|
||||
};
|
||||
|
||||
struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
- public UIManagerAnimationDelegate,
|
||||
+ public UIManagerCommitHook,
|
||||
public std::enable_shared_from_this<LayoutAnimationsProxy_Legacy> {
|
||||
mutable std::unordered_map<Tag, std::shared_ptr<Node>> nodeForTag_;
|
||||
mutable std::recursive_mutex mutex;
|
||||
@@ -116,11 +116,11 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
const SharedComponentDescriptorRegistry &componentDescriptorRegistry,
|
||||
const std::shared_ptr<const ContextContainer> &contextContainer,
|
||||
jsi::Runtime &uiRuntime,
|
||||
- const std::shared_ptr<UIScheduler> &uiScheduler
|
||||
+ const std::shared_ptr<UIScheduler> &uiScheduler,
|
||||
+ const std::shared_ptr<UIManager> &uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
const PreserveMountedTagsFunction &filterUnmountedTagsFunction,
|
||||
- const std::shared_ptr<UIManager> &uiManager,
|
||||
const std::shared_ptr<CallInvoker> &jsInvoker
|
||||
#endif
|
||||
)
|
||||
@@ -129,14 +129,19 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
componentDescriptorRegistry,
|
||||
contextContainer,
|
||||
uiRuntime,
|
||||
- uiScheduler
|
||||
+ uiScheduler,
|
||||
+ uiManager
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction,
|
||||
- uiManager,
|
||||
jsInvoker
|
||||
#endif
|
||||
) {
|
||||
+ uiManager->registerCommitHook(*this);
|
||||
+ }
|
||||
+
|
||||
+ ~LayoutAnimationsProxy_Legacy() override {
|
||||
+ uiManager_->unregisterCommitHook(*this);
|
||||
}
|
||||
|
||||
void startEnteringAnimation(const int tag, ShadowViewMutation &mutation) const;
|
||||
@@ -202,19 +207,15 @@ struct LayoutAnimationsProxy_Legacy : public LayoutAnimationsProxyCommon,
|
||||
const TransactionTelemetry &telemetry,
|
||||
ShadowViewMutationList mutations) const override;
|
||||
|
||||
- // UIManagerAnimationDelegate
|
||||
-
|
||||
- void uiManagerDidConfigureNextLayoutAnimation(
|
||||
- jsi::Runtime &runtime,
|
||||
- const RawValue &config,
|
||||
- const jsi::Value &successCallbackValue,
|
||||
- const jsi::Value &failureCallbackValue) const override;
|
||||
-
|
||||
- void setComponentDescriptorRegistry(const SharedComponentDescriptorRegistry &componentDescriptorRegistry) override;
|
||||
+ // UIManagerCommitHook
|
||||
|
||||
- bool shouldAnimateFrame() const override;
|
||||
+ void commitHookWasRegistered(const UIManager &uiManager) noexcept override {}
|
||||
+ void commitHookWasUnregistered(const UIManager &uiManager) noexcept override {}
|
||||
|
||||
- void stopSurface(SurfaceId surfaceId) override;
|
||||
+ RootShadowNode::Unshared shadowTreeWillCommit(
|
||||
+ const ShadowTree &shadowTree,
|
||||
+ const RootShadowNode::Shared &oldRootShadowNode,
|
||||
+ const RootShadowNode::Unshared &newRootShadowNode) noexcept override;
|
||||
};
|
||||
|
||||
} // namespace reanimated
|
||||
diff --git a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
index 9ade22bf773005613048a00c47b35767628e86c6..f3415e824da1a8da5c83762415ca54646bd6429f 100644
|
||||
--- a/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
+++ b/Common/cpp/reanimated/NativeModules/ReanimatedModuleProxy.cpp
|
||||
@@ -524,15 +524,13 @@ jsi::Value ReanimatedModuleProxy::getSettledUpdates(jsi::Runtime &rt) {
|
||||
StaticFeatureFlags::getFlag("FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS") &&
|
||||
"getSettledUpdates requires FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS static feature flag to be enabled");
|
||||
|
||||
+ constexpr double SETTLED_ANIMATION_THRESHOLD_MS = 1000;
|
||||
+
|
||||
// TODO(future): use unified timestamp
|
||||
const auto currentTimestamp = getAnimationTimestamp_();
|
||||
|
||||
- // TODO: fix bug when threshold difference is smaller than 1 second
|
||||
// TODO(future): flush updates from CSS animations and CSS transitions registries
|
||||
- // TODO(future): find a better way to obtain timestamp for removing updates
|
||||
- // TODO(future): move removing old updates to separate method
|
||||
- return animatedPropsRegistry_->getUpdatesOlderThanTimestamp(
|
||||
- rt, currentTimestamp - 1000 /* 1 second */, currentTimestamp - 2000 /* 2 seconds */);
|
||||
+ return animatedPropsRegistry_->collectSettledUpdates(rt, currentTimestamp - SETTLED_ANIMATION_THRESHOLD_MS);
|
||||
}
|
||||
|
||||
bool ReanimatedModuleProxy::handleEvent(
|
||||
@@ -1306,11 +1304,11 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
|
||||
componentDescriptorRegistry,
|
||||
scheduler->getContextContainer(),
|
||||
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
|
||||
- uiScheduler_
|
||||
+ uiScheduler_,
|
||||
+ uiManager_
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction_,
|
||||
- uiManager_,
|
||||
jsInvoker_
|
||||
#endif
|
||||
);
|
||||
@@ -1319,22 +1317,19 @@ void ReanimatedModuleProxy::initializeLayoutAnimationsProxy() {
|
||||
#endif
|
||||
layoutAnimationsProxy_ = std::move(layoutAnimationsProxyExperimental);
|
||||
} else {
|
||||
- auto layoutAnimationsProxyLegacy = std::make_shared<LayoutAnimationsProxy_Legacy>(
|
||||
+ layoutAnimationsProxy_ = std::make_shared<LayoutAnimationsProxy_Legacy>(
|
||||
layoutAnimationsManager_,
|
||||
componentDescriptorRegistry,
|
||||
scheduler->getContextContainer(),
|
||||
getJSIRuntimeFromWorkletRuntime(uiRuntime_),
|
||||
- uiScheduler_
|
||||
+ uiScheduler_,
|
||||
+ uiManager_
|
||||
#ifdef ANDROID
|
||||
,
|
||||
filterUnmountedTagsFunction_,
|
||||
- uiManager_,
|
||||
jsInvoker_
|
||||
#endif
|
||||
);
|
||||
- // TODO (future): support in experimental
|
||||
- uiManager_->setAnimationDelegate(layoutAnimationsProxyLegacy.get());
|
||||
- layoutAnimationsProxy_ = std::move(layoutAnimationsProxyLegacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
diff --git a/src/PropsRegistryGarbageCollector.ts b/src/PropsRegistryGarbageCollector.ts
|
||||
index f917ce5a8586c02855f1d8d9ae73154592d22510..32148fbac8a9224ffec6edc784b48938da9585fb 100644
|
||||
--- a/src/PropsRegistryGarbageCollector.ts
|
||||
+++ b/src/PropsRegistryGarbageCollector.ts
|
||||
@@ -11,7 +11,6 @@ import { ReanimatedModule } from './ReanimatedModule';
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
|
||||
export const PropsRegistryGarbageCollector = {
|
||||
- viewsCount: 0,
|
||||
viewsMap: new Map<number, IAnimatedComponentInternal>(),
|
||||
intervalId: null as NodeJS.Timeout | null,
|
||||
|
||||
@@ -25,16 +24,14 @@ export const PropsRegistryGarbageCollector = {
|
||||
return;
|
||||
}
|
||||
this.viewsMap.set(viewTag, component);
|
||||
- this.viewsCount++;
|
||||
- if (this.viewsCount === 1) {
|
||||
+ if (this.viewsMap.size === 1) {
|
||||
this.registerInterval();
|
||||
}
|
||||
},
|
||||
|
||||
unregisterView(viewTag: number) {
|
||||
- this.viewsMap.delete(viewTag);
|
||||
- this.viewsCount--;
|
||||
- if (this.viewsCount === 0) {
|
||||
+ const deleted = this.viewsMap.delete(viewTag);
|
||||
+ if (deleted && this.viewsMap.size === 0) {
|
||||
this.unregisterInterval();
|
||||
}
|
||||
},
|
||||
@@ -0,0 +1,65 @@
|
||||
# react-native-reanimated@4.3.2.patch
|
||||
|
||||
Backports of two merged upstream PRs:
|
||||
|
||||
1. PR 9901 (`LayoutAnimation.configureNext` compatibility)
|
||||
2. PR 9971 (stale `settledProps` on worklet re-animation / after app resume)
|
||||
|
||||
## 1. Backport of PR 9901
|
||||
|
||||
Backport of https://github.com/software-mansion/react-native-reanimated/pull/9901
|
||||
("refactor(LayoutAnimations): stop taking over UIManagerAnimationDelegate").
|
||||
|
||||
Reanimated's legacy `LayoutAnimationsProxy_Legacy` registered itself as the
|
||||
`UIManagerAnimationDelegate` only to receive `stopSurface`. Occupying that slot
|
||||
overwrites the `LayoutAnimationDriver` that React Native installs there, which
|
||||
silently breaks `LayoutAnimation.configureNext` for the whole app.
|
||||
|
||||
The patch makes the proxy detect surface teardown itself via a
|
||||
`UIManagerCommitHook` (a commit with an empty root marks the surface in
|
||||
`surfacesToRemove_`), frees the animation-delegate slot, and drops final
|
||||
keyframe `Update` mutations for views deleted in the same transaction (a
|
||||
deterministic `configureNext` delete-animation crash found in this app).
|
||||
`uiManager` moves from Android-only to shared constructor args since the hook
|
||||
registration needs it on both platforms.
|
||||
|
||||
Only the `packages/react-native-reanimated` part of the PR is included (the
|
||||
`apps/fabric-example` hunk is not part of the published package), and the
|
||||
include hunk in `LayoutAnimationsProxy_Legacy.cpp` was adjusted to the 4.3.2
|
||||
release sources.
|
||||
|
||||
## 2. Backport of PR 9971 (stale `settledProps`)
|
||||
|
||||
Verbatim application of
|
||||
https://github.com/software-mansion/react-native-reanimated/pull/9971, the
|
||||
4.3-stable cherry-pick of
|
||||
https://github.com/software-mansion/react-native-reanimated/pull/9527
|
||||
("Fix stale settledProps on worklet re-animation"). Fixes the Android DM
|
||||
composer "phantom jump"
|
||||
(https://github.com/software-mansion/react-native-reanimated/issues/9574).
|
||||
|
||||
Background: with `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS`, once an
|
||||
animation settles its final props are handed to JS (polled every 500 ms by
|
||||
`PropsRegistryGarbageCollector`) and stored in React component state
|
||||
(`settledProps`), after which the React-side snapshot becomes the sole owner
|
||||
of the value.
|
||||
|
||||
The PR replaces `getUpdatesOlderThanTimestamp` (which evicted registry
|
||||
entries on a wall-clock 1 s/2 s window) with `collectSettledUpdates`:
|
||||
|
||||
- `syncedTags_` / `invalidatedTags_` track which tags React already has a
|
||||
snapshot for; when a previously-synced view re-animates, its stale snapshot
|
||||
is refreshed on the next GC tick instead of waiting for the new value to
|
||||
settle.
|
||||
- Eviction is no longer time-based. An entry is only evicted on the tick
|
||||
*after* it was returned to JS (once its `settledProps` commit is
|
||||
guaranteed), so a missed timer window (app backgrounded, JS thread blocked)
|
||||
can no longer destroy a settled value before it reaches React. This
|
||||
replaces the ad-hoc eviction guard an earlier version of this patch added
|
||||
on top of the pre-merge PR 9527.
|
||||
- `PropsRegistryGarbageCollector` drops the separate `viewsCount` counter
|
||||
(which could desync when nested animated components unregister a tag that
|
||||
was never registered, stopping the GC interval while views remain) in favor
|
||||
of `viewsMap.size`. Only `src/` is touched, matching the PR; Metro bundles
|
||||
the app from `src/` via the package's `react-native` field, and the stale
|
||||
`lib/` copy is unreachable (the feature is native-only).
|
||||
@@ -1,35 +0,0 @@
|
||||
diff --git a/ios/RNUITextViewShadow.swift b/ios/RNUITextViewShadow.swift
|
||||
index c34ba712ca628ed8cf2db0f9fc332810ec86d34d..3602856dc8cd926b5321b4ecb109be9c00a23fe6 100644
|
||||
--- a/ios/RNUITextViewShadow.swift
|
||||
+++ b/ios/RNUITextViewShadow.swift
|
||||
@@ -159,13 +159,25 @@ class RNUITextViewShadow: RCTShadowView {
|
||||
let maxSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(MAXFLOAT))
|
||||
let textSize = self.attributedText.boundingRect(with: maxSize, options: .usesLineFragmentOrigin, context: nil)
|
||||
|
||||
- var totalLines = self.lineHeight == 0.0 ? 0 : Int(ceil(textSize.height / self.lineHeight))
|
||||
-
|
||||
- if self.numberOfLines != 0, totalLines > self.numberOfLines {
|
||||
- totalLines = self.numberOfLines
|
||||
+ var finalHeight: CGFloat
|
||||
+
|
||||
+ if self.numberOfLines != 0 && self.lineHeight != 0.0 {
|
||||
+ // numberOfLines is set with custom line height - need to calculate lines and snap to lineHeight multiples
|
||||
+ // NOTE: this calculation can be inaccurate with fractional font sizes
|
||||
+ var totalLines = Int(ceil(textSize.height / self.lineHeight))
|
||||
+ if totalLines > self.numberOfLines {
|
||||
+ totalLines = self.numberOfLines
|
||||
+ }
|
||||
+ finalHeight = CGFloat(totalLines) * self.lineHeight
|
||||
+ } else {
|
||||
+ // Either no numberOfLines limit, or no custom lineHeight - use actual text height
|
||||
+ // (numberOfLines without custom lineHeight is handled by the UITextView's textContainer.maximumNumberOfLines)
|
||||
+ finalHeight = textSize.height
|
||||
}
|
||||
|
||||
- self.frameSize = CGSize(width: CGFloat(maxWidth), height: CGFloat(CGFloat(totalLines) * self.lineHeight))
|
||||
+ finalHeight = ceil(finalHeight)
|
||||
+
|
||||
+ self.frameSize = CGSize(width: CGFloat(maxWidth), height: finalHeight)
|
||||
return YGSize(width: Float(self.frameSize.width), height: Float(self.frameSize.height))
|
||||
}
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
index c593d9ee2155a826352ebca34845aa5792b2eec3..3c26cd737f21116ff0aa48190e97e6c0649b5fac 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm
|
||||
@@ -101,6 +101,20 @@ - (void)setContentOffset:(CGPoint)contentOffset
|
||||
RCTSanitizeNaNValue(contentOffset.y, @"scrollView.contentOffset.y"));
|
||||
}
|
||||
|
||||
+- (void)setCenterContent:(BOOL)centerContent
|
||||
+{
|
||||
+ if (_centerContent != centerContent) {
|
||||
+ _centerContent = centerContent;
|
||||
+ [self centerContentIfNeeded];
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+- (void)setContentSize:(CGSize)contentSize
|
||||
+{
|
||||
+ [super setContentSize:contentSize];
|
||||
+ [self centerContentIfNeeded];
|
||||
+}
|
||||
+
|
||||
- (void)setFrame:(CGRect)frame
|
||||
{
|
||||
[super setFrame:frame];
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
||||
index 914a2494a57923fbf185644b7e2bb8aca8848e56..0deac55f22350f5e8377d8963fb1c2434bf6abfd 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h
|
||||
@@ -11,11 +36,60 @@ index 914a2494a57923fbf185644b7e2bb8aca8848e56..0deac55f22350f5e8377d8963fb1c243
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
index 0d231bc8aa938da296eb3b981e8ac9595a43b87f..be0a10d9c4de1892fa00bcbf8d63d739b66d8ffe 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.mm
|
||||
@@ -76,7 +76,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
return;
|
||||
}
|
||||
|
||||
- const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(*_props);
|
||||
+ /*
|
||||
+ * TODO: Remove after upgrading React Native to 0.82+ (fixed upstream by
|
||||
+ * facebook/react-native#52615, #52584 and #53231).
|
||||
+ * Diff against oldProps instead of _props. During the initial-layout replay
|
||||
+ * from layoutSubviews, _props already holds the new props, so diffing
|
||||
+ * against it is a no-op and tintColor/progressViewOffset are never applied
|
||||
+ * on mount (facebook/react-native#56343). oldProps is null-guarded because
|
||||
+ * the create-mutation path passes nullptr.
|
||||
+ */
|
||||
+ const auto &oldConcreteProps = static_cast<const PullToRefreshViewProps &>(
|
||||
+ oldProps ? *oldProps : *PullToRefreshViewShadowNode::defaultSharedProps());
|
||||
const auto &newConcreteProps = static_cast<const PullToRefreshViewProps &>(*props);
|
||||
|
||||
if (newConcreteProps.tintColor != oldConcreteProps.tintColor) {
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..df643f5c844ad2e684de5161528eba17f4a188d0 100644
|
||||
index 1494fd225aff1fa0429e917404d6b4ca5fc961c5..682e41b141c38c830bbcd9f2ce0de07b18f13977 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm
|
||||
@@ -1038,6 +1038,11 @@ - (void)_adjustForMaintainVisibleContentPosition
|
||||
@@ -380,7 +380,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
|
||||
|
||||
MAP_SCROLL_VIEW_PROP(zoomScale);
|
||||
|
||||
- if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset) {
|
||||
+ // When disabling centerContent, reset inset to prop value
|
||||
+ // (enabling is handled automatically by the setCenterContent: setter)
|
||||
+ if (oldScrollViewProps.centerContent && !newScrollViewProps.centerContent) {
|
||||
+ _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
||||
+ }
|
||||
+
|
||||
+ // Only apply contentInset from props if centerContent is disabled
|
||||
+ // When centerContent is enabled, the inset is calculated by centerContentIfNeeded
|
||||
+ if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) {
|
||||
_scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset);
|
||||
}
|
||||
|
||||
@@ -507,7 +515,7 @@ - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
|
||||
}
|
||||
}
|
||||
|
||||
- return isPointInside ? self : nil;
|
||||
+ return isPointInside ? _scrollView : nil;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -1038,6 +1046,11 @@ - (void)_adjustForMaintainVisibleContentPosition
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,6 +224,23 @@ index 8b6571698fc5dd091a0d8980a33bb40295faf305..27c97bfeb6f13907c89f1d85f2bb8b8a
|
||||
reactChoreographer.postFrameCallback(ReactChoreographer.CallbackType.IDLE_EVENT, this)
|
||||
}
|
||||
}
|
||||
diff --git a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
index 216bb23beb023ef6c3ae814c17e05bccbda7fc91..6ad5cc1d9ed5b8cd2df08ad77adca56c6bb58ff4 100644
|
||||
--- a/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
+++ b/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTTextLayoutManager.mm
|
||||
@@ -386,9 +386,10 @@ - (TextMeasurement)_measureTextStorage:(NSTextStorage *)textStorage
|
||||
size.height = enumeratedLinesHeight;
|
||||
}
|
||||
|
||||
+ CGFloat epsilon = 0.001;
|
||||
size = (CGSize){
|
||||
- ceil(size.width * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
- ceil(size.height * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
+ ceil((size.width + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor,
|
||||
+ ceil((size.height + epsilon) * layoutContext.pointScaleFactor) / layoutContext.pointScaleFactor};
|
||||
|
||||
__block auto attachments = TextMeasurement::Attachments{};
|
||||
|
||||
diff --git a/third-party-podspecs/fmt.podspec b/third-party-podspecs/fmt.podspec
|
||||
index 2f38990e226c13f483aaf1b986302d4094243814..9b02e481e290299be20a6f09c42056ff51695e9b 100644
|
||||
--- a/third-party-podspecs/fmt.podspec
|
||||
|
||||
@@ -11,3 +11,61 @@ in the RN repo: https://github.com/facebook/react-native/issues/43388
|
||||
Patching `RCTRefreshControl.m` and `RCTRefreshControl.h` to add a new `forwarderBeginRefreshing` method to the class.
|
||||
This method is used by `ExpoScrollForwarder` to initiate a refresh of the underlying `UIScrollView` from inside that
|
||||
module.
|
||||
|
||||
## RCTPullToRefreshViewComponentView.mm Patch - RefreshControl initial props dropped on New Arch
|
||||
|
||||
**TODO: Remove after bumping React Native to 0.82+** (fixed upstream by facebook/react-native#52615, #52584
|
||||
and #53231).
|
||||
|
||||
On Fabric, `updateProps` diffs against `_props`, but the initial-layout replay in `layoutSubviews` passes
|
||||
`_props` as the new props too, so the diff is a no-op and `tintColor`/`progressViewOffset`/`title` are never
|
||||
applied on mount. This hides the pull-to-refresh spinner behind the floating home header (it stays at offset
|
||||
0 instead of `headerOffset`). We diff against the `oldProps` argument instead, null-guarded with default
|
||||
props for the create-mutation path.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/56343
|
||||
|
||||
## RCTEnhancedScrollView.mm / RCTScrollViewComponentView.mm Patch - centerContent insets stale after content resize on New Arch
|
||||
|
||||
**TODO: Remove after bumping React Native to 0.87+** (fixed upstream by facebook/react-native#56832,
|
||||
commit d50c1b5207; first shipped in 0.87.0-rc.0).
|
||||
|
||||
On Fabric, `centerContent` centers by computing `contentInset` in `centerContentIfNeeded`, but that
|
||||
recompute only ran on `setFrame`/`didAddSubview`/`scrollViewDidZoom` - not when a state update assigns a
|
||||
new `contentSize` in `updateState`. Any content that resizes after mount inside a `centerContent`
|
||||
ScrollView (e.g. the lightbox image crop view getting its real aspect ratio from `onLoad` when the embed
|
||||
has no aspectRatio metadata) keeps the old insets: content rests off-center and the excess inset creates
|
||||
phantom scroll range, so the image can be dragged and parked off-center and the native scroll steals the
|
||||
swipe-down-to-dismiss pan. The old architecture paired every `contentSize` update with re-centering in
|
||||
`RCTScrollView.updateContentSizeIfNeeded`; Fabric dropped that link.
|
||||
|
||||
Backport of the upstream fix: `setContentSize:`/`setCenterContent:` overrides on `RCTEnhancedScrollView`
|
||||
that call `centerContentIfNeeded`, plus the `updateProps` guards so the `contentInset` prop does not
|
||||
fight the computed centering inset.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/55090
|
||||
|
||||
## RCTScrollViewComponentView.mm Patch - ScrollView pinch/pan ignored outside content area on New Arch
|
||||
|
||||
**TODO: Remove after bumping React Native to 0.87+** (fixed upstream by facebook/react-native#56747,
|
||||
commit efcab20908; first shipped in 0.87.0-rc.0).
|
||||
|
||||
On Fabric, `betterHitTest` in `RCTScrollViewComponentView` deliberately skips the `_containerView`
|
||||
and hit-tests its grandchildren, returning `self` (the wrapper component view) when the touch lands
|
||||
inside the scroll view bounds but outside any content. UIKit only delivers touches to a gesture
|
||||
recognizer when the hit view is the recognizer's view or a descendant of it, and the `UIScrollView`
|
||||
is a *child* of the wrapper - so its native pinch/pan recognizers never see those touches. In the
|
||||
lightbox this means pinch-to-zoom and pan-while-zoomed only respond when the fingers are over the
|
||||
image itself, not over the letterbox bars above/below it. On the old architecture, default UIKit
|
||||
hit-testing returns the `UIScrollView` itself for those touches, so everything works.
|
||||
|
||||
Backport of the upstream one-liner: return `_scrollView` instead of `self` so touches in the
|
||||
content-less area are attributed to the `UIScrollView`.
|
||||
|
||||
Issue: https://github.com/facebook/react-native/issues/54123
|
||||
PR: https://github.com/react/react-native/pull/56747
|
||||
|
||||
## RCTTextLayoutManager.mm Patch - Text overflows instead of wrapping on the last line
|
||||
|
||||
Issue: https://github.com/react/react-native/issues/53450#issuecomment-3298157830
|
||||
Bandaid fix taken from: https://github.com/react/react-native/commit/581d643a9e59fd88f93757f80194e1efd11bd0e5
|
||||
|
||||
Generated
+986
-450
File diff suppressed because it is too large
Load Diff
+5
-5
@@ -9,7 +9,8 @@ overrides:
|
||||
'@expo/image-utils': '0.8.12'
|
||||
'@types/estree': '1.0.6'
|
||||
'react-native-compressor': '1.13.0'
|
||||
'react-native-reanimated': '3.19.1'
|
||||
'react-native-reanimated': '4.3.2'
|
||||
'react-native-worklets': '0.8.3'
|
||||
'psl': '1.9.0'
|
||||
'@types/psl': '1.1.1'
|
||||
'react-native-screens': '4.24.0'
|
||||
@@ -20,8 +21,7 @@ allowBuilds:
|
||||
'unrs-resolver': true
|
||||
patchedDependencies:
|
||||
'@sentry/expo-upload-sourcemaps@8.18.0': patches/@sentry__expo-upload-sourcemaps@8.18.0.patch
|
||||
expo-age-range@0.2.18: patches/expo-age-range@0.2.18.patch
|
||||
'expo-glass-effect@55.0.8': patches/expo-glass-effect@55.0.8.patch
|
||||
'expo-age-range@0.2.18': patches/expo-age-range@0.2.18.patch
|
||||
'expo-haptics@15.0.8': patches/expo-haptics@15.0.8.patch
|
||||
'expo-image-picker@17.0.11': patches/expo-image-picker@17.0.11.patch
|
||||
'expo-image@3.0.11': patches/expo-image@3.0.11.patch
|
||||
@@ -32,11 +32,11 @@ patchedDependencies:
|
||||
'react-native-compressor@1.13.0': patches/react-native-compressor@1.13.0.patch
|
||||
'react-native-date-picker@5.0.13': patches/react-native-date-picker@5.0.13.patch
|
||||
'react-native-drawer-layout@4.2.3': patches/react-native-drawer-layout@4.2.3.patch
|
||||
'react-native-gesture-handler': patches/react-native-gesture-handler.patch
|
||||
'react-native-keyboard-controller@1.21.8': patches/react-native-keyboard-controller@1.21.8.patch
|
||||
'react-native-pager-view@6.8.0': patches/react-native-pager-view@6.8.0.patch
|
||||
'react-native-reanimated@3.19.1': patches/react-native-reanimated@3.19.1.patch
|
||||
'react-native-reanimated@4.3.2': patches/react-native-reanimated@4.3.2.patch
|
||||
'react-native-svg@15.12.1': patches/react-native-svg@15.12.1.patch
|
||||
'react-native-uitextview@1.4.0': patches/react-native-uitextview@1.4.0.patch
|
||||
'react-native-view-shot@4.0.3': patches/react-native-view-shot@4.0.3.patch
|
||||
'react-native@0.81.5': patches/react-native@0.81.5.patch
|
||||
'sonner-native@0.21.0': patches/sonner-native@0.21.0.patch
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/bin/bash
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
set -o xtrace
|
||||
|
||||
# Resolve paths relative to the repo root, regardless of where this is run from.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
ANDROID_DIR="$REPO_ROOT/android"
|
||||
APK_OUTPUT_DIR="$ANDROID_DIR/app/build/outputs/apk/release"
|
||||
SETTINGS_GRADLE="$ANDROID_DIR/settings.gradle"
|
||||
|
||||
# Guard against building with the wrong app identity. The New Arch build must
|
||||
# use a distinct rootProject.name so it installs alongside the store app rather
|
||||
# than overwriting it.
|
||||
EXPECTED_APP_NAME="rootProject.name = 'Bluesky (New Arch)'"
|
||||
if ! grep -qF "$EXPECTED_APP_NAME" "$SETTINGS_GRADLE"; then
|
||||
echo "Error: expected \"$EXPECTED_APP_NAME\" in $SETTINGS_GRADLE" >&2
|
||||
echo "(Set the app name in settings.gradle before building the New Arch release.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH_NAME="$(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD)"
|
||||
COMMIT_HASH="$(git -C "$REPO_ROOT" rev-parse --short=6 HEAD)"
|
||||
|
||||
# Sanitize the branch name so it is safe to use in a filename (e.g. ob/new-arch -> ob-new-arch).
|
||||
SAFE_BRANCH="$(echo "$BRANCH_NAME" | tr '/ ' '-')"
|
||||
|
||||
echo "Building Android release APK..."
|
||||
echo " branch: $BRANCH_NAME"
|
||||
echo " commit: $COMMIT_HASH"
|
||||
|
||||
# Marker used to detect which APK was produced by THIS build. Anything with an
|
||||
# older mtime (e.g. a stale app-release.apk from a prior or interrupted run) is
|
||||
# ignored, so we never mislabel it with the current commit.
|
||||
BUILD_MARKER="$(mktemp)"
|
||||
trap 'rm -f "$BUILD_MARKER"' EXIT
|
||||
|
||||
# Make sure the bundled JS ships with up-to-date compiled translations.
|
||||
pnpm intl:compile
|
||||
|
||||
cd "$ANDROID_DIR"
|
||||
# Build only arm64: Apple Silicon Macs run arm64 emulator images and all modern
|
||||
# devices are arm64, so the other three ABIs just quadruple the NDK compile.
|
||||
./gradlew assembleRelease --max-workers=2 --no-daemon -PreactNativeArchitectures=arm64-v8a
|
||||
|
||||
# Grab the freshly built APK: not an already-renamed bsky-* file, and newer than
|
||||
# the marker so it is guaranteed to be this run's output. There are no ABI splits
|
||||
# or flavors, so expect a single file.
|
||||
APK_PATH="$(find "$APK_OUTPUT_DIR" -maxdepth 1 -name '*.apk' -not -name 'bsky-*' -newer "$BUILD_MARKER" | head -n 1)"
|
||||
|
||||
if [ -z "$APK_PATH" ]; then
|
||||
echo "Error: no freshly built APK found in $APK_OUTPUT_DIR" >&2
|
||||
echo "(Gradle may have been up-to-date and produced no new APK - run a clean build.)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PREV_NAME="$(basename "$APK_PATH" .apk)"
|
||||
NEW_NAME="bsky-${PREV_NAME}-${SAFE_BRANCH}-${COMMIT_HASH}.apk"
|
||||
NEW_PATH="$APK_OUTPUT_DIR/$NEW_NAME"
|
||||
|
||||
mv "$APK_PATH" "$NEW_PATH"
|
||||
|
||||
echo "Renamed APK:"
|
||||
echo " $APK_PATH"
|
||||
echo " -> $NEW_PATH"
|
||||
+20
-41
@@ -72,21 +72,26 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
const isDarkMode = colorScheme === 'dark'
|
||||
|
||||
const logoAnimation = useAnimatedStyle(() => {
|
||||
const introScale = interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp')
|
||||
const outroScale =
|
||||
reduceMotion === true
|
||||
? 1
|
||||
: interpolate(outroLogo.get(), [0, 0.08, 1], [1, 0.8, 500], 'clamp')
|
||||
|
||||
const introOpacity = interpolate(intro.get(), [0, 1], [0, 1], 'clamp')
|
||||
const outroOpacity = interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
)
|
||||
|
||||
return {
|
||||
opacity: introOpacity * outroOpacity,
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
outroLogo.get(),
|
||||
[0, 0.08, 1],
|
||||
[1, 0.8, 500],
|
||||
'clamp',
|
||||
),
|
||||
},
|
||||
{translateY: -(insets.top / 2)},
|
||||
{scale: 0.1 * outroScale * introScale},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const bottomLogoAnimation = useAnimatedStyle(() => {
|
||||
@@ -94,27 +99,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
const reducedLogoAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(intro.get(), [0, 1], [0.8, 1], 'clamp'),
|
||||
},
|
||||
],
|
||||
opacity: interpolate(intro.get(), [0, 1], [0, 1], 'clamp'),
|
||||
}
|
||||
})
|
||||
|
||||
const logoWrapperAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[1, 1, 0, 0],
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const appAnimation = useAnimatedStyle(() => {
|
||||
return {
|
||||
@@ -126,7 +110,7 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
opacity: interpolate(
|
||||
outroAppOpacity.get(),
|
||||
[0, 0.1, 0.2, 1],
|
||||
[0, 0, 1, 1],
|
||||
[0.02, 0.02, 1, 1], // first two values cant be 0 for the iOS blur/glass effects to work, the values obtained by trial and error
|
||||
'clamp',
|
||||
),
|
||||
}
|
||||
@@ -180,8 +164,6 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
|
||||
}, [])
|
||||
|
||||
const logoAnimations =
|
||||
reduceMotion === true ? reducedLogoAnimation : logoAnimation
|
||||
// special off-spec color for dark mode
|
||||
const logoBg = isDarkMode ? '#0F1824' : '#fff'
|
||||
|
||||
@@ -224,17 +206,14 @@ export function Splash(props: React.PropsWithChildren<Props>) {
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
logoWrapperAnimation,
|
||||
logoAnimation,
|
||||
{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
transform: [{translateY: -(insets.top / 2)}, {scale: 0.1}], // scale from 1000px to 100px
|
||||
},
|
||||
]}>
|
||||
<Animated.View style={[logoAnimations]}>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
<Logo fill={logoBg} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
import {setPolyfills} from '@growthbook/growthbook'
|
||||
import {GrowthBook} from '@growthbook/growthbook-react'
|
||||
import {type I18n} from '@lingui/core'
|
||||
|
||||
@@ -89,14 +89,12 @@ const SPRING_IN: WithSpringConfig = {
|
||||
mass: 0.75,
|
||||
damping: 300,
|
||||
stiffness: 1200,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
const SPRING_OUT: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 1000,
|
||||
restDisplacementThreshold: 0.01,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
measure,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
runOnJS,
|
||||
scrollTo,
|
||||
type SharedValue,
|
||||
@@ -237,8 +238,8 @@ function SortableItem<T>({
|
||||
itemKey: string
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
state: Animated.SharedValue<DragState>
|
||||
dragY: Animated.SharedValue<number>
|
||||
state: SharedValue<DragState>
|
||||
dragY: SharedValue<number>
|
||||
scrollCompensation: SharedValue<number>
|
||||
isGestureActive: SharedValue<boolean>
|
||||
measureDone: SharedValue<boolean>
|
||||
@@ -370,18 +371,18 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: s.dragStartSlot * itemHeight + dragY.get()},
|
||||
{scale: withSpring(1.03)},
|
||||
{scale: withSpring(1.03, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 999,
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {width: 0, height: 1},
|
||||
shadowOpacity: withSpring(0.08),
|
||||
shadowRadius: withSpring(4),
|
||||
shadowOpacity: withSpring(0.08, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(4, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(3),
|
||||
elevation: withSpring(3, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -391,11 +392,11 @@ function SortableItem<T>({
|
||||
const inactive = {
|
||||
...(IS_IOS
|
||||
? {
|
||||
shadowOpacity: withSpring(0),
|
||||
shadowRadius: withSpring(0),
|
||||
shadowOpacity: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
shadowRadius: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}
|
||||
: {
|
||||
elevation: withSpring(0),
|
||||
elevation: withSpring(0, Reanimated3DefaultSpringConfig),
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -425,7 +426,7 @@ function SortableItem<T>({
|
||||
return {
|
||||
transform: [
|
||||
{translateY: withTiming(baseY + offset, {duration: 200})},
|
||||
{scale: withSpring(1)},
|
||||
{scale: withSpring(1, Reanimated3DefaultSpringConfig)},
|
||||
],
|
||||
zIndex: 0,
|
||||
...inactive,
|
||||
|
||||
@@ -15,6 +15,8 @@ export const IS_GLASS_AVAILABLE =
|
||||
* Liquid Glass View that uses `expo-glass-effect`
|
||||
*
|
||||
* If unavailable, falls back to a regular `View`. Use `fallbackStyle` to customize the fallback appearance.
|
||||
* Note: Setting opacity to 0 on Expo GlassView or any of its parent views causes the glass effect to not render at all. https://docs.expo.dev/versions/v56.0.0/sdk/glass-effect/#known-issues
|
||||
* If animating the opacity of a parent view, start from a non-zero opacity to avoid this issue.
|
||||
*/
|
||||
export const GlassView = IS_GLASS_AVAILABLE ? InnerGlassView : FallbackView
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {useLingui} from '@lingui/react'
|
||||
|
||||
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, tokens, useTheme, web} from '#/alf'
|
||||
import {transparentifyColor} from '#/alf/util/colorGeneration'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
@@ -200,42 +201,44 @@ export function InterestTabs({
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_row]}>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
<BlockDrawerGesture>
|
||||
<DraggableScrollView
|
||||
ref={listRef}
|
||||
contentContainerStyle={[
|
||||
a.gap_sm,
|
||||
{paddingHorizontal: gutterWidth},
|
||||
contentContainerStyle,
|
||||
]}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate="fast"
|
||||
snapToOffsets={
|
||||
tabOffsets.filter(o => !!o).length === interests.length
|
||||
? tabOffsets.map(o => o.x - tokens.space.xl)
|
||||
: undefined
|
||||
}
|
||||
onLayout={evt => setTotalWidth(evt.nativeEvent.layout.width)}
|
||||
onContentSizeChange={width => setContentWidth(width)}
|
||||
onScroll={evt => {
|
||||
const newScrollX = evt.nativeEvent.contentOffset.x
|
||||
setScrollX(newScrollX)
|
||||
}}
|
||||
scrollEventThrottle={16}>
|
||||
{interests.map((interest, i) => {
|
||||
const active = interest === selectedInterest && !disabled
|
||||
return (
|
||||
<TabComponent
|
||||
key={interest}
|
||||
onSelectTab={handleSelectTab}
|
||||
active={active}
|
||||
index={i}
|
||||
interest={interest}
|
||||
interestsDisplayName={interestsDisplayNames[interest]}
|
||||
onLayout={handleTabLayout}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</DraggableScrollView>
|
||||
</BlockDrawerGesture>
|
||||
{IS_WEB && canScrollLeft && (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {forwardRef, memo, useContext, useMemo} from 'react'
|
||||
import {memo, useContext, useMemo} from 'react'
|
||||
import {
|
||||
type StyleProp,
|
||||
View,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
type AnimatedScrollViewProps,
|
||||
useAnimatedStyle,
|
||||
} from 'react-native-reanimated'
|
||||
@@ -73,67 +74,64 @@ export type ContentProps = AnimatedScrollViewProps & {
|
||||
style?: StyleProp<ViewStyle>
|
||||
contentContainerStyle?: StyleProp<ViewStyle>
|
||||
ignoreTabletLayoutOffset?: boolean
|
||||
ref?: AnimatedRef<Animated.ScrollView>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scroll view for simple pages
|
||||
*/
|
||||
export const Content = memo(
|
||||
forwardRef<Animated.ScrollView, ContentProps>(function Content(
|
||||
{
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
export const Content = memo(function Content({
|
||||
children,
|
||||
style,
|
||||
contentContainerStyle,
|
||||
ignoreTabletLayoutOffset,
|
||||
ref,
|
||||
...props
|
||||
}: ContentProps) {
|
||||
const t = useTheme()
|
||||
const {footerHeight} = useShellLayout()
|
||||
const {isWithinSplitView} = useIsWithinSplitView()
|
||||
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
// note - if we ever make the footer transparent in any way,
|
||||
// we'll need to change this to use contentInsets/scrollIndicatorInsets
|
||||
// on iOS and contentContainerStyle padding on Android -sfn
|
||||
const animatedStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
marginBottom: footerHeight.get(),
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
}),
|
||||
)
|
||||
return (
|
||||
<Animated.ScrollView
|
||||
ref={ref}
|
||||
id="content"
|
||||
automaticallyAdjustsScrollIndicatorInsets={false}
|
||||
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
|
||||
style={[
|
||||
a.w_full,
|
||||
animatedStyle,
|
||||
isWithinSplitView &&
|
||||
web({
|
||||
flex: 1,
|
||||
overflowY: 'scroll',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${t.palette.contrast_100} transparent`,
|
||||
}),
|
||||
style,
|
||||
]}
|
||||
contentContainerStyle={[contentContainerStyle]}
|
||||
{...props}>
|
||||
{IS_WEB ? (
|
||||
<Center ignoreTabletLayoutOffset={ignoreTabletLayoutOffset}>
|
||||
{/* @ts-expect-error web only -esb */}
|
||||
{children}
|
||||
</Center>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Animated.ScrollView>
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Utility component to center content within the screen
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from 'react-native-gesture-handler'
|
||||
import Animated, {
|
||||
type AnimatableValue,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
runOnJS,
|
||||
type SharedValue,
|
||||
useAnimatedReaction,
|
||||
@@ -463,7 +464,10 @@ function clampTranslation(
|
||||
|
||||
function withClampedSpring<T extends AnimatableValue>(value: T): T {
|
||||
'worklet'
|
||||
return withSpring(value, {overshootClamping: true})
|
||||
return withSpring(value, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
})
|
||||
}
|
||||
|
||||
export default memo(ImageItem)
|
||||
|
||||
@@ -170,8 +170,6 @@ const ImageItem = ({
|
||||
width: screenSize.width,
|
||||
maxHeight: screenSize.height,
|
||||
alignSelf: 'center',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,11 +178,19 @@ const ImageItem = ({
|
||||
return {
|
||||
transform: cropContentTransform,
|
||||
width: '100%',
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
* When the aspect ratio is unknown until onLoad fires, these layout props
|
||||
* change after mount. They must be applied via a React render rather than
|
||||
* useAnimatedStyle
|
||||
*/
|
||||
const imageLayoutStyle = {
|
||||
aspectRatio: imageAspect ?? 1 /* force onLoad */,
|
||||
opacity: imageAspect === undefined ? 0 : 1,
|
||||
}
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false)
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
useAnimatedReaction(
|
||||
@@ -225,8 +231,8 @@ const ImageItem = ({
|
||||
{showLoader && (
|
||||
<ActivityIndicator size="small" color="#FFF" style={styles.loading} />
|
||||
)}
|
||||
<Animated.View style={imageCropStyle}>
|
||||
<Animated.View style={imageStyle}>
|
||||
<Animated.View style={[imageCropStyle, imageLayoutStyle]}>
|
||||
<Animated.View style={[imageStyle, imageLayoutStyle]}>
|
||||
<Image
|
||||
contentFit="contain"
|
||||
source={{uri: imageSrc.uri}}
|
||||
|
||||
@@ -60,13 +60,11 @@ const SLOW_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 300,
|
||||
stiffness: 800,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
const FAST_SPRING: WithSpringConfig = {
|
||||
mass: IS_IOS ? 1.25 : 0.75,
|
||||
damping: 150,
|
||||
stiffness: 900,
|
||||
restDisplacementThreshold: 0.001,
|
||||
}
|
||||
|
||||
function canAnimate(lightbox: Lightbox): boolean {
|
||||
@@ -532,7 +530,7 @@ function LightboxImage({
|
||||
const dismissTranslateY =
|
||||
isActive && openProgressValue === 1 ? dismissSwipeTranslateY.get() : 0
|
||||
|
||||
if (openProgressValue === 0 && isFlyingAway.get()) {
|
||||
if (openProgressValue === 0) {
|
||||
return {
|
||||
isHidden: true,
|
||||
isResting: false,
|
||||
@@ -609,6 +607,7 @@ function LightboxImage({
|
||||
return withSpring(0, {
|
||||
stiffness: 700,
|
||||
damping: 50,
|
||||
mass: 1,
|
||||
reduceMotion: ReduceMotion.Never,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {type Component} from 'react'
|
||||
import {type TransformsStyle} from 'react-native'
|
||||
import {
|
||||
type AnimatedRef,
|
||||
@@ -29,7 +28,7 @@ export type ImageSource = {
|
||||
thumbUri: string
|
||||
thumbDimensions: Dimensions | null
|
||||
thumbRect: MeasuredDimensions | null
|
||||
thumbRef?: AnimatedRef<Component> | null
|
||||
thumbRef?: AnimatedRef | null
|
||||
thumbBorderRadius?: number
|
||||
alt?: string
|
||||
type: 'image' | 'circle-avi' | 'rect-avi'
|
||||
|
||||
@@ -177,6 +177,7 @@ export function LabelBase({
|
||||
text,
|
||||
a.font_semi_bold,
|
||||
a.leading_tight,
|
||||
a.flex_shrink,
|
||||
t.atoms.text_contrast_medium,
|
||||
{paddingRight: 3},
|
||||
]}>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ImageEmbed({
|
||||
|
||||
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
|
||||
// ref + dims that a tap would — keeps the lightbox's return animation intact.
|
||||
const singleContainerRef = useRef<AnimatedRef<React.Component> | null>(null)
|
||||
const singleContainerRef = useRef<AnimatedRef | null>(null)
|
||||
const singleDimsRef = useRef<Dimensions | null>(null)
|
||||
|
||||
if (images.length > 0) {
|
||||
@@ -71,7 +71,7 @@ export function ImageEmbed({
|
||||
}))
|
||||
const onPress = (
|
||||
index: number,
|
||||
refs: AnimatedRef<React.Component>[],
|
||||
refs: AnimatedRef[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => {
|
||||
if (postContext) {
|
||||
|
||||
@@ -119,7 +119,8 @@ export const ProgressGuideToast = forwardRef<
|
||||
left = right = (winDim.width - 380) / 2
|
||||
}
|
||||
return {
|
||||
position: IS_WEB ? 'fixed' : 'absolute',
|
||||
// position: fixed is web only
|
||||
position: (IS_WEB ? 'fixed' : 'absolute') as 'absolute',
|
||||
top: 0,
|
||||
left,
|
||||
right,
|
||||
@@ -134,12 +135,7 @@ export const ProgressGuideToast = forwardRef<
|
||||
return (
|
||||
isOpen && (
|
||||
<Portal>
|
||||
<Animated.View
|
||||
style={[
|
||||
// @ts-ignore position: fixed is web only
|
||||
containerStyle,
|
||||
animatedStyle,
|
||||
]}>
|
||||
<Animated.View style={[containerStyle, animatedStyle]}>
|
||||
<Pressable
|
||||
style={[
|
||||
t.atoms.bg,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from 'react-native'
|
||||
import Animated, {
|
||||
type AnimatedStyle,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
interpolateColor,
|
||||
@@ -662,7 +663,7 @@ function BlockedPlaceholder({
|
||||
style,
|
||||
}: {
|
||||
profile: Shadow<ChatBskyActorDefs.ProfileViewBasic>
|
||||
style?: StyleProp<ViewStyle>
|
||||
style?: AnimatedStyle<ViewStyle>
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
import {HITSLOP_20} from '#/lib/constants'
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {
|
||||
android,
|
||||
applyFonts,
|
||||
atoms as a,
|
||||
platform,
|
||||
@@ -223,10 +222,6 @@ export function createInput(Component: typeof TextInput) {
|
||||
paddingTop: 13,
|
||||
paddingBottom: 13,
|
||||
},
|
||||
android({
|
||||
paddingTop: 8,
|
||||
paddingBottom: 9,
|
||||
}),
|
||||
/*
|
||||
* Margins are needed here to avoid autofill background overlapping the
|
||||
* top and bottom borders - esb
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {beforeEach, describe, expect, it, jest} from '@jest/globals'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map<string, string>()
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@ export function AnimatedLikeIcon({
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
<Animated.View
|
||||
@@ -121,6 +122,7 @@ export function AnimatedLikeIcon({
|
||||
zIndex: -1,
|
||||
pointerEvents: 'none',
|
||||
borderRadius: size / 2,
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
KeyboardGestureArea,
|
||||
} from 'react-native-keyboard-controller'
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
runOnJS,
|
||||
type ScrollEvent,
|
||||
type SharedValue,
|
||||
@@ -187,6 +186,14 @@ export function MessagesList({
|
||||
}
|
||||
}, [hasScrolled, listOpacity])
|
||||
|
||||
// Recreate FadeIn for the footer with a shared value so we can start from a
|
||||
// non-zero opacity instead of fully transparent. (Needed for GlassView to work properly)
|
||||
const footerOpacity = useSharedValue(0.05)
|
||||
|
||||
useEffect(() => {
|
||||
footerOpacity.set(withTiming(1, {duration: 200}))
|
||||
}, [footerOpacity])
|
||||
|
||||
const inputHeightUI = useSharedValue(0)
|
||||
const [inputHeightJS, setInputHeightJS] = useState(0)
|
||||
|
||||
@@ -753,6 +760,10 @@ export function MessagesList({
|
||||
opacity: listOpacity.get(),
|
||||
}))
|
||||
|
||||
const animatedFooterStyle = useAnimatedStyle(() => ({
|
||||
opacity: footerOpacity.get(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<InviteLinkDialogProvider convo={convoState.convo}>
|
||||
<MessageRepliesProvider scrollToMessage={scrollToMessage}>
|
||||
@@ -841,7 +852,7 @@ export function MessagesList({
|
||||
opened: 0,
|
||||
}}>
|
||||
{footer ?? (
|
||||
<Animated.View entering={FadeIn.duration(200)}>
|
||||
<Animated.View style={animatedFooterStyle}>
|
||||
<ConversationFooter
|
||||
convoState={convoState}
|
||||
hasAcceptOverride={hasAcceptOverride}>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
RQKEY as FEED_RQKEY,
|
||||
} from '#/state/queries/post-feed'
|
||||
import {truncateAndInvalidate} from '#/state/queries/util'
|
||||
import {PostFeed} from '#/view/com/posts/PostFeed'
|
||||
import {PostFeed, type PostFeedRef} from '#/view/com/posts/PostFeed'
|
||||
import {
|
||||
EmptyState,
|
||||
type EmptyStateButtonProps,
|
||||
@@ -36,6 +36,7 @@ interface FeedSectionProps {
|
||||
emptyStateMessage?: string
|
||||
emptyStateButton?: EmptyStateButtonProps
|
||||
emptyStateIcon?: React.ComponentType<any> | React.ReactElement
|
||||
postFeedRef?: React.Ref<PostFeedRef>
|
||||
}
|
||||
|
||||
export function ProfileFeedSection({
|
||||
@@ -49,6 +50,7 @@ export function ProfileFeedSection({
|
||||
emptyStateMessage,
|
||||
emptyStateButton,
|
||||
emptyStateIcon,
|
||||
postFeedRef,
|
||||
}: FeedSectionProps) {
|
||||
const {_} = useLingui()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -111,6 +113,7 @@ export function ProfileFeedSection({
|
||||
shouldUseAdjustedNumToRender ? adjustedInitialNumToRender : undefined
|
||||
}
|
||||
isVideoFeed={isVideoFeed}
|
||||
ref={postFeedRef}
|
||||
/>
|
||||
{(isScrolledDown || hasNew) && (
|
||||
<LoadLatestBtn
|
||||
|
||||
@@ -8,7 +8,6 @@ import {type InfiniteData} from '@tanstack/react-query'
|
||||
import {popularInterests, useInterestsDisplayNames} from '#/lib/interests'
|
||||
import {logger} from '#/logger'
|
||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {boostInterests, InterestTabs} from '#/components/InterestTabs'
|
||||
import * as ProfileCard from '#/components/ProfileCard'
|
||||
@@ -71,26 +70,24 @@ export function SuggestedAccountsTabBar({
|
||||
.sort(boostInterests(personalizedInterests))
|
||||
|
||||
return (
|
||||
<BlockDrawerGesture>
|
||||
<InterestTabs
|
||||
interests={hideDefaultTab ? interests : ['all', ...interests]}
|
||||
selectedInterest={
|
||||
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
||||
}
|
||||
onSelectTab={tab => {
|
||||
ax.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
|
||||
onSelectInterest(tab === 'all' ? null : tab)
|
||||
}}
|
||||
interestsDisplayNames={
|
||||
hideDefaultTab
|
||||
? interestsDisplayNames
|
||||
: {
|
||||
all: defaultTabLabel || _(msg`For You`),
|
||||
...interestsDisplayNames,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</BlockDrawerGesture>
|
||||
<InterestTabs
|
||||
interests={hideDefaultTab ? interests : ['all', ...interests]}
|
||||
selectedInterest={
|
||||
selectedInterest || (hideDefaultTab ? interests[0] : 'all')
|
||||
}
|
||||
onSelectTab={tab => {
|
||||
ax.metric('explore:suggestedAccounts:tabPressed', {tab: tab})
|
||||
onSelectInterest(tab === 'all' ? null : tab)
|
||||
}}
|
||||
interestsDisplayNames={
|
||||
hideDefaultTab
|
||||
? interestsDisplayNames
|
||||
: {
|
||||
all: defaultTabLabel || _(msg`For You`),
|
||||
...interestsDisplayNames,
|
||||
}
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
type NativeStackScreenProps,
|
||||
} from '#/lib/routes/types'
|
||||
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
|
||||
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
|
||||
import {type Alf, atoms as a, native, useAlf, useTheme} from '#/alf'
|
||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||
import {type Props as SVGIconProps} from '#/components/icons/common'
|
||||
@@ -24,7 +23,6 @@ import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/T
|
||||
import {TitleCase_Stroke2_Corner0_Rounded as Aa} from '#/components/icons/TitleCase'
|
||||
import * as Layout from '#/components/Layout'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_INTERNAL, IS_NATIVE} from '#/env'
|
||||
import * as SettingsList from './components/SettingsList'
|
||||
|
||||
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AppearanceSettings'>
|
||||
@@ -165,12 +163,12 @@ export function AppearanceSettingsScreen({}: Props) {
|
||||
onChange={onChangeFontScale}
|
||||
/>
|
||||
|
||||
{IS_NATIVE && IS_INTERNAL && (
|
||||
{/*{IS_NATIVE && IS_INTERNAL && (
|
||||
<>
|
||||
<SettingsList.Divider />
|
||||
<AppIconSettingsListItem />
|
||||
</>
|
||||
)}
|
||||
)}*/}
|
||||
</Animated.View>
|
||||
</SettingsList.Container>
|
||||
</Layout.Content>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
Reanimated3DefaultSpringConfig,
|
||||
type SharedValue,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
@@ -34,6 +35,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
|
||||
'worklet'
|
||||
footerMode.set(() =>
|
||||
withSpring(v ? 1 : 0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import {beforeEach, expect, jest, test} from '@jest/globals'
|
||||
|
||||
import {Storage} from '#/storage'
|
||||
|
||||
jest.mock('@bsky.app/react-native-mmkv', () => ({
|
||||
jest.mock('react-native-mmkv', () => ({
|
||||
MMKV: class MMKVMock {
|
||||
_store = new Map()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
|
||||
import {type DB} from '#/storage/archive/db/types'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useState} from 'react'
|
||||
import {MMKV} from '@bsky.app/react-native-mmkv'
|
||||
import {MMKV} from 'react-native-mmkv'
|
||||
|
||||
import {type Account, type Device} from '#/storage/schema'
|
||||
|
||||
|
||||
@@ -39,9 +39,9 @@ export const SplashScreen = ({
|
||||
logoFill,
|
||||
logoShadow: isDarkMode
|
||||
? [
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowColor: logoFill,
|
||||
shadowRadius: 8,
|
||||
shadowOpacity: 0.5,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
@@ -93,13 +93,15 @@ export const SplashScreen = ({
|
||||
size="large"
|
||||
color={isDarkMode ? 'secondary_inverted' : 'secondary'}
|
||||
style={[
|
||||
t.atoms.shadow_md,
|
||||
{
|
||||
shadowColor: t.palette.black,
|
||||
shadowRadius: 8,
|
||||
shadowOpacity: 0.1,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 5,
|
||||
},
|
||||
elevation: 16,
|
||||
},
|
||||
]}>
|
||||
<ButtonText>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {KeyboardAvoidingView} from 'react-native-keyboard-controller'
|
||||
import ProgressCircle from 'react-native-progress/Circle'
|
||||
import Animated, {
|
||||
type AnimatedRef,
|
||||
type AnimatedStyle,
|
||||
Easing,
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
@@ -1794,7 +1795,7 @@ function ComposerTopBar({
|
||||
isEditingDraft: boolean
|
||||
canSaveDraft: boolean
|
||||
textLength: number
|
||||
topBarAnimatedStyle: StyleProp<ViewStyle>
|
||||
topBarAnimatedStyle: AnimatedStyle<ViewStyle>
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const t = useTheme()
|
||||
@@ -2029,7 +2030,7 @@ function ComposerPills({
|
||||
thread: ThreadDraft
|
||||
post: PostDraft
|
||||
dispatch: (action: ComposerAction) => void
|
||||
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
||||
bottomBarAnimatedStyle: AnimatedStyle<ViewStyle>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const media = post.embed.media
|
||||
|
||||
@@ -227,7 +227,6 @@ export function TextInput({
|
||||
allowFontScaling
|
||||
multiline
|
||||
scrollEnabled={false}
|
||||
numberOfLines={2}
|
||||
// Note: should be the default value, but as of v1.104
|
||||
// it switched to "none" on Android
|
||||
autoCapitalize="sentences"
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
AppState,
|
||||
@@ -195,6 +203,10 @@ export function getItemsForFeedback(feedRow: FeedRow): {
|
||||
}
|
||||
}
|
||||
|
||||
export type PostFeedRef = {
|
||||
refreshFeed: () => Promise<void>
|
||||
}
|
||||
|
||||
// DISABLED need to check if this is causing random feed refreshes -prf
|
||||
// const REFRESH_AFTER = STALE.HOURS.ONE
|
||||
const CHECK_LATEST_AFTER = STALE.SECONDS.THIRTY
|
||||
@@ -222,6 +234,7 @@ let PostFeed = ({
|
||||
savedFeedConfig,
|
||||
initialNumToRender: initialNumToRenderOverride,
|
||||
isVideoFeed = false,
|
||||
ref,
|
||||
}: {
|
||||
feed: FeedDescriptor
|
||||
description?: RichTextType
|
||||
@@ -246,6 +259,7 @@ let PostFeed = ({
|
||||
initialNumToRender?: number
|
||||
isVideoFeed?: boolean
|
||||
lastFetchDate?: () => number
|
||||
ref?: React.Ref<PostFeedRef>
|
||||
}): React.ReactNode => {
|
||||
const ax = useAnalytics()
|
||||
const t = useTheme()
|
||||
@@ -725,8 +739,9 @@ let PostFeed = ({
|
||||
|
||||
// events
|
||||
// =
|
||||
//
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
const refreshFeed = async () => {
|
||||
if (!enabled) return
|
||||
|
||||
ax.metric('feed:refresh', {
|
||||
@@ -734,24 +749,23 @@ let PostFeed = ({
|
||||
feedUrl: feed,
|
||||
reason: 'pull-to-refresh',
|
||||
})
|
||||
setIsPTRing(true)
|
||||
try {
|
||||
await truncateAndInvalidate(queryClient, RQKEY(feed, feedParams))
|
||||
onHasNew?.(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to refresh posts feed', {message: err})
|
||||
}
|
||||
}
|
||||
|
||||
const onRefresh = async () => {
|
||||
setIsPTRing(true)
|
||||
await refreshFeed()
|
||||
setIsPTRing(false)
|
||||
}, [
|
||||
ax,
|
||||
queryClient,
|
||||
setIsPTRing,
|
||||
onHasNew,
|
||||
feed,
|
||||
feedParams,
|
||||
feedType,
|
||||
enabled,
|
||||
])
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
refreshFeed,
|
||||
}))
|
||||
|
||||
const onEndReached = useCallback(async () => {
|
||||
if (isFetching || !hasNextPage || isError) return
|
||||
|
||||
@@ -3,6 +3,7 @@ import {type NativeScrollEvent} from 'react-native'
|
||||
import {
|
||||
clamp,
|
||||
interpolate,
|
||||
Reanimated3DefaultSpringConfig,
|
||||
type SharedValue,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
@@ -105,6 +106,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
|
||||
'worklet'
|
||||
headerMode.set(() =>
|
||||
withSpring(v ? 1 : 0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'
|
||||
import {ActivityIndicator, StyleSheet} from 'react-native'
|
||||
import {withSpring} from 'react-native-reanimated'
|
||||
import {
|
||||
Reanimated3DefaultSpringConfig,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated'
|
||||
import {useFocusEffect} from '@react-navigation/native'
|
||||
|
||||
import {PROD_DEFAULT_FEED} from '#/lib/constants'
|
||||
@@ -147,7 +150,12 @@ function HomeScreenReady({
|
||||
const headerMode = useHomeHeaderMode()
|
||||
const showHeader = useCallback(() => {
|
||||
'worklet'
|
||||
headerMode.set(() => withSpring(0, {overshootClamping: true}))
|
||||
headerMode.set(() =>
|
||||
withSpring(0, {
|
||||
...Reanimated3DefaultSpringConfig,
|
||||
overshootClamping: true,
|
||||
}),
|
||||
)
|
||||
}, [headerMode])
|
||||
|
||||
useFocusEffect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
|
||||
import {StyleSheet} from 'react-native'
|
||||
import {SafeAreaView} from 'react-native-safe-area-context'
|
||||
import {ScrollForwarderView} from 'react-native-scroll-forwarder/src'
|
||||
import {
|
||||
type AppBskyActorDefs,
|
||||
moderateProfile,
|
||||
@@ -36,6 +37,7 @@ import {useAgent, useSession} from '#/state/session'
|
||||
import {ProfileFeedgens} from '#/view/com/feeds/ProfileFeedgens'
|
||||
import {ProfileLists} from '#/view/com/lists/ProfileLists'
|
||||
import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader'
|
||||
import {type PostFeedRef} from '#/view/com/posts/PostFeed'
|
||||
import {ErrorScreen} from '#/view/com/util/error/ErrorScreen'
|
||||
import {FAB} from '#/view/com/util/fab/FAB'
|
||||
import {type ListRef} from '#/view/com/util/List'
|
||||
@@ -53,7 +55,6 @@ import * as Layout from '#/components/Layout'
|
||||
import {ScreenHider} from '#/components/moderation/ScreenHider'
|
||||
import {ProfileStarterPacks} from '#/components/StarterPack/ProfileStarterPacks'
|
||||
import {navigate} from '#/Navigation'
|
||||
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
|
||||
|
||||
interface SectionRef {
|
||||
scrollToTop: () => void
|
||||
@@ -187,6 +188,7 @@ function ProfileScreenLoaded({
|
||||
enabled: !!profile.associated?.labeler,
|
||||
})
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const {_} = useLingui()
|
||||
|
||||
const [scrollViewTag, setScrollViewTag] = useState<number | null>(null)
|
||||
@@ -353,6 +355,14 @@ function ProfileScreenLoaded({
|
||||
],
|
||||
})
|
||||
|
||||
const postFeedRef = useRef<PostFeedRef>(null)
|
||||
|
||||
const onRefresh = async () => {
|
||||
setIsRefreshing(true)
|
||||
await postFeedRef.current?.refreshFeed()
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
|
||||
// rendering
|
||||
// =
|
||||
|
||||
@@ -362,7 +372,10 @@ function ProfileScreenLoaded({
|
||||
setMinimumHeight: (height: number) => void
|
||||
}) => {
|
||||
return (
|
||||
<ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
|
||||
<ScrollForwarderView
|
||||
scrollViewTag={scrollViewTag}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}>
|
||||
<ProfileHeader
|
||||
profile={profile}
|
||||
labeler={labelerInfo}
|
||||
@@ -372,7 +385,7 @@ function ProfileScreenLoaded({
|
||||
isPlaceholderProfile={showPlaceholder}
|
||||
setMinimumHeight={setMinimumHeight}
|
||||
/>
|
||||
</ExpoScrollForwarderView>
|
||||
</ScrollForwarderView>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -440,6 +453,7 @@ function ProfileScreenLoaded({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
postFeedRef={postFeedRef}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -2,6 +2,11 @@ import {useContext} from 'react'
|
||||
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
|
||||
/**
|
||||
* BlockDrawerGesture must wrap the ScrollView directly - Gesture.Native()
|
||||
* only works when attached to the natively scrollable view. On the new
|
||||
* arch (Android), attaching it to a wrapper view breaks scrolling.
|
||||
*/
|
||||
export function BlockDrawerGesture({children}: {children: React.ReactNode}) {
|
||||
const drawerGesture = useContext(DrawerGestureContext) ?? Gesture.Native() // noop for web
|
||||
let scrollGesture = Gesture.Native()
|
||||
|
||||
Reference in New Issue
Block a user