Make bio area scrollable on iOS (#2931)

* fix dampen logic

prevent ghost presses

handle refreshes, animations, and clamps

handle most cases for cancelling the scroll animation

handle animations

save point

simplify

remove unnecessary context

readme

apply offset on pan

find the RCTScrollView

send props, add native gesture recognizer

get the react tag

wrap the profile in context

create module

* fix swiping to go back

* remove debug

* use `findNodeHandle`

* create an expo module view

* port most of it to expo modules

* finish most of expomodules impl

* experiments

* remove refresh ability for now

* remove rn module

* changes

* cleanup a few issues

allow swipe back gesture

clean up types

always run animation if the final offset is < 0

separate logic

update patch readme

get the `RCTRefreshControl` working nicely

* gate new header

* organize
zio/stable
Hailey 2024-04-11 15:20:38 -07:00 committed by GitHub
parent 740cd029d7
commit 4e51772003
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 491 additions and 65 deletions

View File

@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollForwarderModule"]
}
}

View File

@ -0,0 +1 @@
export {ExpoScrollForwarderView} from './src/ExpoScrollForwarderView'

View File

@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollForwarder'
s.version = '1.0.0'
s.summary = 'Forward scroll gesture from UIView to UIScrollView'
s.description = 'Forward scroll gesture from UIView to UIScrollView'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

View File

@ -0,0 +1,13 @@
import ExpoModulesCore
public class ExpoScrollForwarderModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollForwarder")
View(ExpoScrollForwarderView.self) {
Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
view.scrollViewTag = prop
}
}
}
}

View File

@ -0,0 +1,215 @@
import ExpoModulesCore
// This view will be used as a native component. Make sure to inherit from `ExpoView`
// to apply the proper styling (e.g. border radius and shadows).
class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
var scrollViewTag: Int? {
didSet {
self.tryFindScrollView()
}
}
private var rctScrollView: RCTScrollView?
private var rctRefreshCtrl: RCTRefreshControl?
private var cancelGestureRecognizers: [UIGestureRecognizer]?
private var animTimer: Timer?
private var initialOffset: CGFloat = 0.0
private var didImpact: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
pg.delegate = self
self.addGestureRecognizer(pg)
let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
tg.isEnabled = false
tg.delegate = self
let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
lpg.minimumPressDuration = 0.01
lpg.isEnabled = false
lpg.delegate = self
self.cancelGestureRecognizers = [lpg, tg]
}
// We don't want to recognize the scroll pan gesture and the swipe back gesture together
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
return false
}
return true
}
// We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
// interfere with the native swipe back gesture.
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return true
}
let velocity = gestureRecognizer.velocity(in: self)
return abs(velocity.y) > abs(velocity.x)
}
// This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
// recognizer for this one.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
self.stopTimer()
}
// This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
// the scroll view gesture's delegate, so we add an additional recognizer to detect this.
@IBAction func callOnPress(_ sender: UITapGestureRecognizer) -> Void {
self.stopTimer()
}
@IBAction func callOnPan(_ sender: UIPanGestureRecognizer) -> Void {
guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
return
}
let translation = sender.translation(in: self).y
if sender.state == .began {
if sv.contentOffset.y < 0 {
sv.contentOffset.y = 0
}
self.initialOffset = sv.contentOffset.y
}
if sender.state == .changed {
sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
if sv.contentOffset.y <= -130, !didImpact {
let generator = UIImpactFeedbackGenerator(style: .light)
generator.impactOccurred()
self.didImpact = true
}
}
if sender.state == .ended {
let velocity = sender.velocity(in: self).y
self.didImpact = false
if sv.contentOffset.y <= -130 {
self.rctRefreshCtrl?.forwarderBeginRefreshing()
return
}
// A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
// scroll view
if abs(velocity) < 250, sv.contentOffset.y >= 0 {
return
}
self.startDecayAnimation(translation, velocity)
}
}
func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
guard let sv = self.rctScrollView?.scrollView else {
return
}
var velocity = velocity
self.enableCancelGestureRecognizers()
if velocity > 0 {
velocity = min(velocity, 5000)
} else {
velocity = max(velocity, -5000)
}
var animTranslation = -translation
self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { timer in
velocity *= 0.9875
animTranslation = (-velocity / 120) + animTranslation
let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
if nextOffset <= 0 {
if self.initialOffset <= 1 {
self.scrollToOffset(0)
} else {
sv.contentOffset.y = 0
}
self.stopTimer()
return
} else {
sv.contentOffset.y = nextOffset
}
if abs(velocity) < 5 {
self.stopTimer()
}
}
}
func dampenOffset(_ offset: CGFloat) -> CGFloat {
if offset < 0 {
return offset - (offset * 0.55)
}
return offset
}
func tryFindScrollView() {
guard let scrollViewTag = scrollViewTag else {
return
}
// Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
// Otherwise we might end up with duplicates when we switch back to that scrollview.
self.removeCancelGestureRecognizers()
self.rctScrollView = self.appContext?
.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
self.addCancelGestureRecognizers()
}
func addCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.addGestureRecognizer(r)
}
}
func removeCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
}
}
func enableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = true
}
}
func disableCancelGestureRecognizers() {
self.cancelGestureRecognizers?.forEach { r in
r.isEnabled = false
}
}
func scrollToOffset(_ offset: Int, animated: Bool = true) -> Void {
self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
}
func stopTimer() -> Void {
self.disableCancelGestureRecognizers()
self.animTimer?.invalidate()
self.animTimer = nil
}
}

View File

@ -0,0 +1,6 @@
import React from 'react'
export interface ExpoScrollForwarderViewProps {
scrollViewTag: number | null
children: React.ReactNode
}

View File

@ -0,0 +1,13 @@
import {requireNativeViewManager} from 'expo-modules-core'
import * as React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
requireNativeViewManager('ExpoScrollForwarder')
export function ExpoScrollForwarderView({
children,
...rest
}: ExpoScrollForwarderViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}

View File

@ -0,0 +1,7 @@
import React from 'react'
import {ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
export function ExpoScrollForwarderView({
children,
}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
return children
}

View File

@ -1,11 +1,22 @@
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
index e9b330f..1ecdf0a 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.h
@@ -16,4 +16,6 @@
@property (nonatomic, copy) RCTDirectEventBlock onRefresh;
@property (nonatomic, weak) UIScrollView *scrollView;
+- (void)forwarderBeginRefreshing;
+
@end
diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m diff --git a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
index b09e653..d290dab 100644 index b09e653..4c32b31 100644
--- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m --- a/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
+++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m +++ b/node_modules/react-native/React/Views/RefreshControl/RCTRefreshControl.m
@@ -198,6 +198,14 @@ - (void)refreshControlValueChanged @@ -198,9 +198,53 @@ - (void)refreshControlValueChanged
[self setCurrentRefreshingState:super.refreshing]; [self setCurrentRefreshingState:super.refreshing];
_refreshingProgrammatically = NO; _refreshingProgrammatically = NO;
+ if (@available(iOS 17.4, *)) { + if (@available(iOS 17.4, *)) {
+ if (_currentRefreshingState) { + if (_currentRefreshingState) {
+ UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; + UIImpactFeedbackGenerator *feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
@ -16,4 +27,43 @@ index b09e653..d290dab 100644
+ +
if (_onRefresh) { if (_onRefresh) {
_onRefresh(nil); _onRefresh(nil);
} }
}
+/*
+ This method is used by Bluesky's ExpoScrollForwarder. This allows other React Native
+ libraries to perform a refresh of a scrollview and access the refresh control's onRefresh
+ function.
+ */
+- (void)forwarderBeginRefreshing
+{
+ _refreshingProgrammatically = NO;
+
+ [self sizeToFit];
+
+ if (!self.scrollView) {
+ return;
+ }
+
+ UIScrollView *scrollView = (UIScrollView *)self.scrollView;
+
+ [UIView animateWithDuration:0.3
+ delay:0
+ options:UIViewAnimationOptionBeginFromCurrentState
+ animations:^(void) {
+ // Whenever we call this method, the scrollview will always be at a position of
+ // -130 or less. Scrolling back to -65 simulates the default behavior of RCTRefreshControl
+ [scrollView setContentOffset:CGPointMake(0, -65)];
+ }
+ completion:^(__unused BOOL finished) {
+ [super beginRefreshing];
+ [self setCurrentRefreshingState:super.refreshing];
+
+ if (self->_onRefresh) {
+ self->_onRefresh(nil);
+ }
+ }
+ ];
+}
+
@end

View File

@ -1,5 +1,13 @@
# RefreshControl Patch # ***This second part of this patch is load bearing, do not remove.***
## RefreshControl Patch - iOS 17.4 Haptic Regression
Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since Patching `RCTRefreshControl.mm` temporarily to play an impact haptic on refresh when using iOS 17.4 or higher. Since
17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update 17.4, there has been a regression somewhere causing haptics to not play on iOS on refresh. Should monitor for an update
in the RN repo: https://github.com/facebook/react-native/issues/43388 in the RN repo: https://github.com/facebook/react-native/issues/43388
## RefreshControl Path - ScrollForwarder
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.

View File

@ -1,18 +1,19 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {findNodeHandle, View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {ListRef} from 'view/com/util/List' import {useQueryClient} from '@tanstack/react-query'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState' import {isNative} from '#/platform/detection'
import {FeedDescriptor} from '#/state/queries/post-feed' import {FeedDescriptor} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {useQueryClient} from '@tanstack/react-query'
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {Text} from '#/view/com/util/text/Text'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {isNative} from '#/platform/detection' import {Text} from '#/view/com/util/text/Text'
import {Feed} from 'view/com/posts/Feed'
import {EmptyState} from 'view/com/util/EmptyState'
import {ListRef} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {SectionRef} from './types' import {SectionRef} from './types'
interface FeedSectionProps { interface FeedSectionProps {
@ -21,12 +22,20 @@ interface FeedSectionProps {
isFocused: boolean isFocused: boolean
scrollElRef: ListRef scrollElRef: ListRef
ignoreFilterFor?: string ignoreFilterFor?: string
setScrollViewTag: (tag: number | null) => void
} }
export const ProfileFeedSection = React.forwardRef< export const ProfileFeedSection = React.forwardRef<
SectionRef, SectionRef,
FeedSectionProps FeedSectionProps
>(function FeedSectionImpl( >(function FeedSectionImpl(
{feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor}, {
feed,
headerHeight,
isFocused,
scrollElRef,
ignoreFilterFor,
setScrollViewTag,
},
ref, ref,
) { ) {
const {_} = useLingui() const {_} = useLingui()
@ -50,6 +59,13 @@ export const ProfileFeedSection = React.forwardRef<
return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} /> return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
}, [_]) }, [_])
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return ( return (
<View> <View>
<Feed <Feed

View File

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import {View} from 'react-native' import {findNodeHandle, View} from 'react-native'
import {useSafeAreaFrame} from 'react-native-safe-area-context' import {useSafeAreaFrame} from 'react-native-safe-area-context'
import { import {
AppBskyLabelerDefs, AppBskyLabelerDefs,
@ -32,6 +32,8 @@ interface LabelsSectionProps {
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
scrollElRef: ListRef scrollElRef: ListRef
headerHeight: number headerHeight: number
isFocused: boolean
setScrollViewTag: (tag: number | null) => void
} }
export const ProfileLabelsSection = React.forwardRef< export const ProfileLabelsSection = React.forwardRef<
SectionRef, SectionRef,
@ -44,6 +46,8 @@ export const ProfileLabelsSection = React.forwardRef<
moderationOpts, moderationOpts,
scrollElRef, scrollElRef,
headerHeight, headerHeight,
isFocused,
setScrollViewTag,
}, },
ref, ref,
) { ) {
@ -63,6 +67,13 @@ export const ProfileLabelsSection = React.forwardRef<
scrollToTop: onScrollToTop, scrollToTop: onScrollToTop,
})) }))
React.useEffect(() => {
if (isFocused && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [isFocused, scrollElRef, setScrollViewTag])
return ( return (
<CenteredView style={{flex: 1, minHeight}} sideBorders> <CenteredView style={{flex: 1, minHeight}} sideBorders>
{isLabelerLoading ? ( {isLabelerLoading ? (

View File

@ -1,22 +1,29 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {
findNodeHandle,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {List, ListRef} from '../util/List'
import {FeedSourceCardLoaded} from './FeedSourceCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileFeedgensQuery, RQKEY} from '#/state/queries/profile-feedgens'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext' import {useTheme} from '#/lib/ThemeContext'
import {usePreferencesQuery} from '#/state/queries/preferences' import {logger} from '#/logger'
import {hydrateFeedGenerator} from '#/state/queries/feed'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useLingui} from '@lingui/react' import {hydrateFeedGenerator} from '#/state/queries/feed'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {RQKEY, useProfileFeedgensQuery} from '#/state/queries/profile-feedgens'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {FeedSourceCardLoaded} from './FeedSourceCard'
const LOADING = {_reactKey: '__loading__'} const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'} const EMPTY = {_reactKey: '__empty__'}
@ -34,13 +41,14 @@ interface ProfileFeedgensProps {
enabled?: boolean enabled?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
testID?: string testID?: string
setScrollViewTag: (tag: number | null) => void
} }
export const ProfileFeedgens = React.forwardRef< export const ProfileFeedgens = React.forwardRef<
SectionRef, SectionRef,
ProfileFeedgensProps ProfileFeedgensProps
>(function ProfileFeedgensImpl( >(function ProfileFeedgensImpl(
{did, scrollElRef, headerOffset, enabled, style, testID}, {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref, ref,
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
@ -169,6 +177,13 @@ export const ProfileFeedgens = React.forwardRef<
[error, refetch, onPressRetryLoadMore, pal, preferences, _], [error, refetch, onPressRetryLoadMore, pal, preferences, _],
) )
React.useEffect(() => {
if (enabled && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [enabled, scrollElRef, setScrollViewTag])
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
<List <List

View File

@ -1,21 +1,28 @@
import React from 'react' import React from 'react'
import {StyleProp, StyleSheet, View, ViewStyle} from 'react-native' import {
findNodeHandle,
StyleProp,
StyleSheet,
View,
ViewStyle,
} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {List, ListRef} from '../util/List'
import {ListCard} from './ListCard'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {useProfileListsQuery, RQKEY} from '#/state/queries/profile-lists'
import {logger} from '#/logger'
import {Trans, msg} from '@lingui/macro'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {useTheme} from '#/lib/ThemeContext' import {useTheme} from '#/lib/ThemeContext'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useLingui} from '@lingui/react' import {RQKEY, useProfileListsQuery} from '#/state/queries/profile-lists'
import {useAnalytics} from 'lib/analytics/analytics'
import {usePalette} from 'lib/hooks/usePalette'
import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
import {ErrorMessage} from '../util/error/ErrorMessage'
import {List, ListRef} from '../util/List'
import {LoadMoreRetryBtn} from '../util/LoadMoreRetryBtn'
import {Text} from '../util/text/Text'
import {ListCard} from './ListCard'
const LOADING = {_reactKey: '__loading__'} const LOADING = {_reactKey: '__loading__'}
const EMPTY = {_reactKey: '__empty__'} const EMPTY = {_reactKey: '__empty__'}
@ -33,11 +40,12 @@ interface ProfileListsProps {
enabled?: boolean enabled?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
testID?: string testID?: string
setScrollViewTag: (tag: number | null) => void
} }
export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>( export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
function ProfileListsImpl( function ProfileListsImpl(
{did, scrollElRef, headerOffset, enabled, style, testID}, {did, scrollElRef, headerOffset, enabled, style, testID, setScrollViewTag},
ref, ref,
) { ) {
const pal = usePalette('default') const pal = usePalette('default')
@ -171,6 +179,13 @@ export const ProfileLists = React.forwardRef<SectionRef, ProfileListsProps>(
[error, refetch, onPressRetryLoadMore, pal, _], [error, refetch, onPressRetryLoadMore, pal, _],
) )
React.useEffect(() => {
if (enabled && scrollElRef.current) {
const nativeTag = findNodeHandle(scrollElRef.current)
setScrollViewTag(nativeTag)
}
}, [enabled, scrollElRef, setScrollViewTag])
return ( return (
<View testID={testID} style={style}> <View testID={testID} style={style}>
<List <List

View File

@ -12,9 +12,7 @@ import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {isInvalidHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {listenSoftReset} from '#/state/events'
import {useLabelerInfoQuery} from '#/state/queries/labeler' import {useLabelerInfoQuery} from '#/state/queries/labeler'
import {resetProfilePostsQueries} from '#/state/queries/post-feed' import {resetProfilePostsQueries} from '#/state/queries/post-feed'
import {useModerationOpts} from '#/state/queries/preferences' import {useModerationOpts} from '#/state/queries/preferences'
@ -27,13 +25,17 @@ import {useAnalytics} from 'lib/analytics/analytics'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
import {ComposeIcon2} from 'lib/icons' import {ComposeIcon2} from 'lib/icons'
import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types'
import {useGate} from 'lib/statsig/statsig'
import {combinedDisplayName} from 'lib/strings/display-names' import {combinedDisplayName} from 'lib/strings/display-names'
import {isInvalidHandle} from 'lib/strings/handles'
import {colors, s} from 'lib/styles' import {colors, s} from 'lib/styles'
import {listenSoftReset} from 'state/events'
import {PagerWithHeader} from 'view/com/pager/PagerWithHeader' import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header' import {ProfileHeader, ProfileHeaderLoading} from '#/screens/Profile/Header'
import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed' import {ProfileFeedSection} from '#/screens/Profile/Sections/Feed'
import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels' import {ProfileLabelsSection} from '#/screens/Profile/Sections/Labels'
import {ScreenHider} from '#/components/moderation/ScreenHider' import {ScreenHider} from '#/components/moderation/ScreenHider'
import {ExpoScrollForwarderView} from '../../../modules/expo-scroll-forwarder'
import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens' import {ProfileFeedgens} from '../com/feeds/ProfileFeedgens'
import {ProfileLists} from '../com/lists/ProfileLists' import {ProfileLists} from '../com/lists/ProfileLists'
import {ErrorScreen} from '../com/util/error/ErrorScreen' import {ErrorScreen} from '../com/util/error/ErrorScreen'
@ -141,6 +143,7 @@ function ProfileScreenLoaded({
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {screen, track} = useAnalytics() const {screen, track} = useAnalytics()
const shouldUseScrollableHeader = useGate('new_profile_scroll_component')
const { const {
data: labelerInfo, data: labelerInfo,
error: labelerError, error: labelerError,
@ -152,6 +155,9 @@ function ProfileScreenLoaded({
const [currentPage, setCurrentPage] = React.useState(0) const [currentPage, setCurrentPage] = React.useState(0)
const {_} = useLingui() const {_} = useLingui()
const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled()
const [scrollViewTag, setScrollViewTag] = React.useState<number | null>(null)
const postsSectionRef = React.useRef<SectionRef>(null) const postsSectionRef = React.useRef<SectionRef>(null)
const repliesSectionRef = React.useRef<SectionRef>(null) const repliesSectionRef = React.useRef<SectionRef>(null)
const mediaSectionRef = React.useRef<SectionRef>(null) const mediaSectionRef = React.useRef<SectionRef>(null)
@ -297,12 +303,9 @@ function ProfileScreenLoaded({
openComposer({mention}) openComposer({mention})
}, [openComposer, currentAccount, track, profile]) }, [openComposer, currentAccount, track, profile])
const onPageSelected = React.useCallback( const onPageSelected = React.useCallback((i: number) => {
(i: number) => { setCurrentPage(i)
setCurrentPage(i) }, [])
},
[setCurrentPage],
)
const onCurrentPageSelected = React.useCallback( const onCurrentPageSelected = React.useCallback(
(index: number) => { (index: number) => {
@ -315,21 +318,38 @@ function ProfileScreenLoaded({
// = // =
const renderHeader = React.useCallback(() => { const renderHeader = React.useCallback(() => {
return ( if (shouldUseScrollableHeader) {
<ProfileHeader return (
profile={profile} <ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
labeler={labelerInfo} <ProfileHeader
descriptionRT={hasDescription ? descriptionRT : null} profile={profile}
moderationOpts={moderationOpts} labeler={labelerInfo}
hideBackButton={hideBackButton} descriptionRT={hasDescription ? descriptionRT : null}
isPlaceholderProfile={showPlaceholder} moderationOpts={moderationOpts}
/> hideBackButton={hideBackButton}
) isPlaceholderProfile={showPlaceholder}
/>
</ExpoScrollForwarderView>
)
} else {
return (
<ProfileHeader
profile={profile}
labeler={labelerInfo}
descriptionRT={hasDescription ? descriptionRT : null}
moderationOpts={moderationOpts}
hideBackButton={hideBackButton}
isPlaceholderProfile={showPlaceholder}
/>
)
}
}, [ }, [
shouldUseScrollableHeader,
scrollViewTag,
profile, profile,
labelerInfo, labelerInfo,
descriptionRT,
hasDescription, hasDescription,
descriptionRT,
moderationOpts, moderationOpts,
hideBackButton, hideBackButton,
showPlaceholder, showPlaceholder,
@ -349,7 +369,7 @@ function ProfileScreenLoaded({
onCurrentPageSelected={onCurrentPageSelected} onCurrentPageSelected={onCurrentPageSelected}
renderHeader={renderHeader}> renderHeader={renderHeader}>
{showFiltersTab {showFiltersTab
? ({headerHeight, scrollElRef}) => ( ? ({headerHeight, isFocused, scrollElRef}) => (
<ProfileLabelsSection <ProfileLabelsSection
ref={labelsSectionRef} ref={labelsSectionRef}
labelerInfo={labelerInfo} labelerInfo={labelerInfo}
@ -358,6 +378,8 @@ function ProfileScreenLoaded({
moderationOpts={moderationOpts} moderationOpts={moderationOpts}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
headerHeight={headerHeight} headerHeight={headerHeight}
isFocused={isFocused}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -369,6 +391,7 @@ function ProfileScreenLoaded({
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight} headerOffset={headerHeight}
enabled={isFocused} enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -381,6 +404,7 @@ function ProfileScreenLoaded({
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -393,6 +417,7 @@ function ProfileScreenLoaded({
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -405,6 +430,7 @@ function ProfileScreenLoaded({
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -417,6 +443,7 @@ function ProfileScreenLoaded({
isFocused={isFocused} isFocused={isFocused}
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
ignoreFilterFor={profile.did} ignoreFilterFor={profile.did}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -428,6 +455,7 @@ function ProfileScreenLoaded({
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight} headerOffset={headerHeight}
enabled={isFocused} enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}
@ -439,6 +467,7 @@ function ProfileScreenLoaded({
scrollElRef={scrollElRef as ListRef} scrollElRef={scrollElRef as ListRef}
headerOffset={headerHeight} headerOffset={headerHeight}
enabled={isFocused} enabled={isFocused}
setScrollViewTag={setScrollViewTag}
/> />
) )
: null} : null}