mirror of
https://github.com/Tencent/Hippy.git
synced 2026-10-24 18:20:12 +08:00
814 lines
32 KiB
Plaintext
814 lines
32 KiB
Plaintext
/*!
|
|
* iOS SDK
|
|
*
|
|
* Tencent is pleased to support the open source community by making
|
|
* Hippy available.
|
|
*
|
|
* Copyright (C) 2019 THL A29 Limited, a Tencent company.
|
|
* All rights reserved.
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
#import "HippyScrollView.h"
|
|
|
|
#import <UIKit/UIKit.h>
|
|
|
|
#import "HippyConvert.h"
|
|
#import "HippyEventDispatcher.h"
|
|
#import "HippyUIManager.h"
|
|
#import "UIView+Private.h"
|
|
#import "UIView+Hippy.h"
|
|
#import "HippyInvalidating.h"
|
|
#import "HippyI18nUtils.h"
|
|
#import "objc/runtime.h"
|
|
|
|
@interface HippyCustomScrollView : UIScrollView <UIGestureRecognizerDelegate>
|
|
|
|
@property (nonatomic, assign) BOOL centerContent;
|
|
|
|
@end
|
|
|
|
@implementation HippyCustomScrollView
|
|
|
|
- (instancetype)initWithFrame:(CGRect)frame {
|
|
if ((self = [super initWithFrame:frame])) {
|
|
[self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)];
|
|
if (@available(iOS 11.0, *)) {
|
|
self.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
|
|
} else {
|
|
// Fallback on earlier versions
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (UIView *)contentView {
|
|
return ((HippyScrollView *)self.superview).contentView;
|
|
}
|
|
|
|
/**
|
|
* @return Whether or not the scroll view interaction should be blocked because
|
|
* JS was found to be the responder.
|
|
*/
|
|
- (BOOL)_shouldDisableScrollInteraction {
|
|
// Since this may be called on every pan, we need to make sure to only climb
|
|
// the hierarchy on rare occasions.
|
|
UIView *JSResponder = [HippyUIManager JSResponder];
|
|
if (JSResponder && JSResponder != self.superview) {
|
|
BOOL superviewHasResponder = [self isDescendantOfView:JSResponder];
|
|
return superviewHasResponder;
|
|
}
|
|
return NO;
|
|
}
|
|
|
|
- (void)handleCustomPan:(__unused UIPanGestureRecognizer *)sender {
|
|
if ([self _shouldDisableScrollInteraction] && ![[HippyUIManager JSResponder] isKindOfClass:[HippyScrollView class]]) {
|
|
self.panGestureRecognizer.enabled = NO;
|
|
self.panGestureRecognizer.enabled = YES;
|
|
// TODO: If mid bounce, animate the scroll view to a non-bounced position
|
|
// while disabling (but only if `stopScrollInteractionIfJSHasResponder` was
|
|
// called *during* a `pan`. Currently, it will just snap into place which
|
|
// is not so bad either.
|
|
// Another approach:
|
|
// self.scrollEnabled = NO;
|
|
// self.scrollEnabled = YES;
|
|
}
|
|
}
|
|
|
|
- (void)scrollRectToVisible:(__unused CGRect)rect animated:(__unused BOOL)animated {
|
|
// noop
|
|
}
|
|
|
|
/**
|
|
* Returning `YES` cancels touches for the "inner" `view` and causes a scroll.
|
|
* Returning `NO` causes touches to be directed to that inner view and prevents
|
|
* the scroll view from scrolling.
|
|
*
|
|
* `YES` -> Allows scrolling.
|
|
* `NO` -> Doesn't allow scrolling.
|
|
*
|
|
* By default this returns NO for all views that are UIControls and YES for
|
|
* everything else. What that does is allows scroll views to scroll even when a
|
|
* touch started inside of a `UIControl` (`UIButton` etc). For Hippy scroll
|
|
* views, we want the default to be the same behavior as `UIControl`s so we
|
|
* return `YES` by default. But there's one case where we want to block the
|
|
* scrolling no matter what: When JS believes it has its own responder lock on
|
|
* a view that is *above* the scroll view in the hierarchy. So we abuse this
|
|
* `touchesShouldCancelInContentView` API in order to stop the scroll view from
|
|
* scrolling in this case.
|
|
*
|
|
* We are not aware of *any* other solution to the problem because alternative
|
|
* approaches require that we disable the scrollview *before* touches begin or
|
|
* move. This approach (`touchesShouldCancelInContentView`) works even if the
|
|
* JS responder is set after touches start/move because
|
|
* `touchesShouldCancelInContentView` is called as soon as the scroll view has
|
|
* been touched and dragged *just* far enough to decide to begin the "drag"
|
|
* movement of the scroll interaction. Returning `NO`, will cause the drag
|
|
* operation to fail.
|
|
*
|
|
* `touchesShouldCancelInContentView` will stop the *initialization* of a
|
|
* scroll pan gesture and most of the time this is sufficient. On rare
|
|
* occasion, the scroll gesture would have already initialized right before JS
|
|
* notifies native of the JS responder being set. In order to recover from that
|
|
* timing issue we have a fallback that kills any ongoing pan gesture that
|
|
* occurs when native is notified of a JS responder.
|
|
*
|
|
* Note: Explicitly returning `YES`, instead of relying on the default fixes
|
|
* (at least) one bug where if you have a UIControl inside a UIScrollView and
|
|
* tap on the UIControl and then start dragging (to scroll), it won't scroll.
|
|
* Chat with andras for more details.
|
|
*
|
|
* In order to have this called, you must have delaysContentTouches set to NO
|
|
* (which is the not the `UIKit` default).
|
|
*/
|
|
- (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view {
|
|
// TODO: shouldn't this call super if _shouldDisableScrollInteraction returns NO?
|
|
return ![self _shouldDisableScrollInteraction];
|
|
}
|
|
|
|
/*
|
|
* Automatically centers the content such that if the content is smaller than the
|
|
* ScrollView, we force it to be centered, but when you zoom or the content otherwise
|
|
* becomes larger than the ScrollView, there is no padding around the content but it
|
|
* can still fill the whole view.
|
|
*/
|
|
- (void)setContentOffset:(CGPoint)contentOffset {
|
|
UIView *contentView = [self contentView];
|
|
if (contentView && _centerContent) {
|
|
CGSize subviewSize = contentView.frame.size;
|
|
CGSize scrollViewSize = self.bounds.size;
|
|
if (subviewSize.width <= scrollViewSize.width) {
|
|
contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0;
|
|
}
|
|
if (subviewSize.height <= scrollViewSize.height) {
|
|
contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0;
|
|
}
|
|
}
|
|
super.contentOffset = contentOffset;
|
|
}
|
|
|
|
@end
|
|
|
|
@implementation HippyScrollView {
|
|
HippyCustomScrollView *_scrollView;
|
|
UIView *_contentView;
|
|
NSTimeInterval _lastScrollDispatchTime;
|
|
BOOL _allowNextScrollNoMatterWhat;
|
|
CGRect _lastClippedToRect;
|
|
NSHashTable *_scrollListeners;
|
|
// The last non-zero value of translationAlongAxis from scrollViewWillEndDragging.
|
|
// Tells if user was scrolling forward or backward and is used to determine a correct
|
|
// snap index when the user stops scrolling with a tap on the scroll view.
|
|
CGFloat _lastNonZeroTranslationAlongAxis;
|
|
NSMutableDictionary *_contentOffsetCache;
|
|
BOOL _didSetContentOffset;
|
|
__weak HippyRootView *_rootView;
|
|
}
|
|
|
|
- (instancetype)initWithEventDispatcher:(HippyEventDispatcher *)eventDispatcher {
|
|
HippyAssertParam(eventDispatcher);
|
|
|
|
if ((self = [super initWithFrame:CGRectZero])) {
|
|
_scrollView = [[HippyCustomScrollView alloc] initWithFrame:CGRectZero];
|
|
_scrollView.delegate = self;
|
|
_scrollView.delaysContentTouches = NO;
|
|
_automaticallyAdjustContentInsets = YES;
|
|
_contentInset = UIEdgeInsetsZero;
|
|
_contentSize = CGSizeZero;
|
|
_lastClippedToRect = CGRectNull;
|
|
|
|
_scrollEventThrottle = 0.0;
|
|
_lastScrollDispatchTime = 0;
|
|
|
|
_scrollListeners = [NSHashTable weakObjectsHashTable];
|
|
_contentOffsetCache = [NSMutableDictionary dictionaryWithCapacity:32];
|
|
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didReceiveMemoryWarning) name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
|
|
[self addSubview:_scrollView];
|
|
if ([self needsLayoutForRTL]) {
|
|
_scrollView.transform = CGAffineTransformMakeRotation(M_PI);
|
|
}
|
|
}
|
|
return self;
|
|
}
|
|
|
|
- (void)didReceiveMemoryWarning {
|
|
[_contentOffsetCache removeAllObjects];
|
|
}
|
|
|
|
- (void)invalidate {
|
|
[_scrollListeners removeAllObjects];
|
|
}
|
|
|
|
HIPPY_NOT_IMPLEMENTED(-(instancetype)initWithFrame : (CGRect)frame)
|
|
HIPPY_NOT_IMPLEMENTED(-(instancetype)initWithCoder : (NSCoder *)aDecoder)
|
|
|
|
- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews {
|
|
// Does nothing
|
|
}
|
|
|
|
- (void)insertHippySubview:(UIView *)view atIndex:(NSInteger)atIndex {
|
|
if (view == _contentView && 0 == atIndex) {
|
|
return;
|
|
}
|
|
HippyAssert(0 == atIndex, @"HippyScrollView only contain one subview at index 0");
|
|
if (_contentView) {
|
|
[self removeHippySubview:_contentView];
|
|
}
|
|
_contentView = view;
|
|
[_contentView addObserver:self forKeyPath:@"frame" options:NSKeyValueObservingOptionNew context:nil];
|
|
[view onAttachedToWindow];
|
|
[_scrollView addSubview:view];
|
|
|
|
if (_didSetContentOffset) {
|
|
_didSetContentOffset = NO;
|
|
return;
|
|
}
|
|
/**
|
|
* reset its contentOffset when subviews are ready
|
|
*/
|
|
NSString *offsetString = [_contentOffsetCache objectForKey:self.hippyTag];
|
|
if (offsetString) {
|
|
CGPoint point = CGPointFromString(offsetString);
|
|
if (CGRectContainsPoint(_contentView.frame, point)) {
|
|
self.scrollView.contentOffset = point;
|
|
}
|
|
}
|
|
else {
|
|
self.scrollView.contentOffset = CGPointZero;
|
|
}
|
|
}
|
|
|
|
- (NSArray<UIView *> *)hippySubviews {
|
|
return _contentView ? [NSMutableArray arrayWithObject:_contentView] : nil;
|
|
}
|
|
|
|
- (void)observeValueForKeyPath:(NSString *)keyPath
|
|
ofObject:(id)object
|
|
change:(__unused NSDictionary<NSKeyValueChangeKey, id> *)change
|
|
context:(__unused void *)context {
|
|
if ([keyPath isEqualToString:@"frame"]) {
|
|
if (object == _contentView) {
|
|
[self hippyBridgeDidFinishTransaction];
|
|
if ([self needsLayoutForRTL]) {
|
|
_contentView.transform = CGAffineTransformMakeRotation(M_PI);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)removeHippySubview:(UIView *)subview {
|
|
[super removeHippySubview:subview];
|
|
HippyAssert(_contentView == subview, @"Attempted to remove non-existent subview");
|
|
[_contentView removeObserver:self forKeyPath:@"frame"];
|
|
_contentView = nil;
|
|
}
|
|
|
|
- (void)didUpdateHippySubviews
|
|
{
|
|
// Do nothing, as subviews are managed by `insertHippySubview:atIndex:`
|
|
}
|
|
|
|
- (BOOL)centerContent {
|
|
return _scrollView.centerContent;
|
|
}
|
|
|
|
- (void)setCenterContent:(BOOL)centerContent {
|
|
_scrollView.centerContent = centerContent;
|
|
}
|
|
|
|
- (void)setClipsToBounds:(BOOL)clipsToBounds {
|
|
super.clipsToBounds = clipsToBounds;
|
|
_scrollView.clipsToBounds = clipsToBounds;
|
|
}
|
|
|
|
- (void)dealloc {
|
|
_scrollView.delegate = nil;
|
|
[[NSNotificationCenter defaultCenter] removeObserver:self name:UIApplicationDidReceiveMemoryWarningNotification object:nil];
|
|
if (_contentView) {
|
|
[_contentView removeObserver:self forKeyPath:@"frame"];
|
|
_contentView = nil;
|
|
}
|
|
}
|
|
|
|
- (void)layoutSubviews {
|
|
[super layoutSubviews];
|
|
HippyAssert(self.subviews.count == 1, @"we should only have exactly one subview");
|
|
HippyAssert([self.subviews lastObject] == _scrollView, @"our only subview should be a scrollview");
|
|
|
|
if (_scrollView.pagingEnabled) {
|
|
//下面计算index,currIndex的计算需要使用scrollview原contentSize除以原frame
|
|
CGRect originFrame = CGRectEqualToRect(CGRectZero, _scrollView.frame) ? self.bounds : _scrollView.frame;
|
|
_scrollView.frame = self.bounds;
|
|
if (CGRectGetWidth(originFrame) > 0) {
|
|
NSInteger currIndex = _scrollView.contentOffset.x / CGRectGetWidth(originFrame);
|
|
//解决HippyScrollView横竖屏切换时 didScrollView没有回调onScroll的问题
|
|
_allowNextScrollNoMatterWhat = YES;
|
|
_scrollView.contentOffset = CGPointMake(currIndex * CGRectGetWidth(_scrollView.frame), 0);
|
|
}
|
|
} else {
|
|
//横竖屏切换后如果仍然保持原有的contentOffset可能会造成offset超过contentsize,需要先做个计算避免此情况
|
|
CGPoint originalOffset = _scrollView.contentOffset;
|
|
_scrollView.frame = self.bounds;
|
|
CGRect frame = self.bounds;
|
|
CGSize contentSize = _scrollView.contentSize;
|
|
if (originalOffset.x + frame.size.width > contentSize.width) {
|
|
CGFloat temp = contentSize.width - frame.size.width;
|
|
originalOffset.x = MAX(0, temp);
|
|
}
|
|
if (originalOffset.y + frame.size.height > contentSize.height) {
|
|
CGFloat temp = contentSize.height - frame.size.height;
|
|
originalOffset.y = MAX(0, temp);
|
|
}
|
|
|
|
_scrollView.contentOffset = originalOffset;
|
|
}
|
|
|
|
[self updateClippedSubviews];
|
|
}
|
|
|
|
- (void)updateClippedSubviews {
|
|
// Find a suitable view to use for clipping
|
|
UIView *clipView = [self hippy_findClipView];
|
|
if (!clipView) {
|
|
return;
|
|
}
|
|
|
|
static const CGFloat leeway = 1.0;
|
|
|
|
const CGSize contentSize = _scrollView.contentSize;
|
|
const CGRect bounds = _scrollView.bounds;
|
|
const BOOL scrollsHorizontally = contentSize.width > bounds.size.width;
|
|
const BOOL scrollsVertically = contentSize.height > bounds.size.height;
|
|
|
|
const BOOL shouldClipAgain
|
|
= CGRectIsNull(_lastClippedToRect) || !CGRectEqualToRect(_lastClippedToRect, bounds)
|
|
|| (scrollsHorizontally && (bounds.size.width < leeway || fabs(_lastClippedToRect.origin.x - bounds.origin.x) >= leeway))
|
|
|| (scrollsVertically && (bounds.size.height < leeway || fabs(_lastClippedToRect.origin.y - bounds.origin.y) >= leeway));
|
|
|
|
if (shouldClipAgain) {
|
|
const CGRect clipRect = CGRectInset(clipView.bounds, -leeway, -leeway);
|
|
[self hippy_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
|
|
_lastClippedToRect = bounds;
|
|
}
|
|
}
|
|
|
|
- (void)setContentInset:(UIEdgeInsets)contentInset {
|
|
if (UIEdgeInsetsEqualToEdgeInsets(contentInset, _contentInset)) {
|
|
return;
|
|
}
|
|
|
|
CGPoint contentOffset = _scrollView.contentOffset;
|
|
|
|
_contentInset = contentInset;
|
|
[HippyView autoAdjustInsetsForView:self withScrollView:_scrollView updateOffset:NO];
|
|
|
|
_scrollView.contentOffset = contentOffset;
|
|
}
|
|
|
|
- (void)scrollToOffset:(CGPoint)offset {
|
|
[self scrollToOffset:offset animated:YES];
|
|
}
|
|
|
|
- (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated {
|
|
if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
|
|
// Ensure at least one scroll event will fire
|
|
_allowNextScrollNoMatterWhat = YES;
|
|
|
|
[self setTargetOffset:offset];
|
|
[_scrollView setContentOffset:offset animated:animated];
|
|
}
|
|
}
|
|
|
|
- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated {
|
|
[_scrollView zoomToRect:rect animated:animated];
|
|
}
|
|
|
|
- (void)refreshContentInset {
|
|
[HippyView autoAdjustInsetsForView:self withScrollView:_scrollView updateOffset:YES];
|
|
}
|
|
|
|
#pragma mark - ScrollView delegate
|
|
|
|
- (void)addScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener {
|
|
[_scrollListeners addObject:scrollListener];
|
|
}
|
|
|
|
- (void)removeScrollListener:(NSObject<UIScrollViewDelegate> *)scrollListener {
|
|
[_scrollListeners removeObject:scrollListener];
|
|
}
|
|
|
|
- (UIScrollView *)realScrollView {
|
|
return _scrollView;
|
|
}
|
|
|
|
- (NSHashTable *)scrollListeners {
|
|
return _scrollListeners;
|
|
}
|
|
|
|
- (NSDictionary *)scrollEventBody {
|
|
NSDictionary *body = @{
|
|
@"contentOffset": @ { @"x": @(_scrollView.contentOffset.x), @"y": @(_scrollView.contentOffset.y) },
|
|
@"contentInset": @ {
|
|
@"top": @(_scrollView.contentInset.top),
|
|
@"left": @(_scrollView.contentInset.left),
|
|
@"bottom": @(_scrollView.contentInset.bottom),
|
|
@"right": @(_scrollView.contentInset.right)
|
|
},
|
|
@"contentSize": @ { @"width": @(_scrollView.contentSize.width), @"height": @(_scrollView.contentSize.height) },
|
|
@"layoutMeasurement": @ { @"width": @(_scrollView.frame.size.width), @"height": @(_scrollView.frame.size.height) },
|
|
@"zoomScale": @(_scrollView.zoomScale ?: 1),
|
|
};
|
|
|
|
return body;
|
|
}
|
|
|
|
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
|
|
[self updateClippedSubviews];
|
|
|
|
NSTimeInterval now = CACurrentMediaTime();
|
|
|
|
/**
|
|
* TODO: this logic looks wrong, and it may be because it is. Currently, if _scrollEventThrottle
|
|
* is set to zero (the default), the "didScroll" event is only sent once per scroll, instead of repeatedly
|
|
* while scrolling as expected. However, if you "fix" that bug, ScrollView will generate repeated
|
|
* warnings, and behave strangely (ListView works fine however), so don't fix it unless you fix that too!
|
|
*/
|
|
NSTimeInterval ti = now - _lastScrollDispatchTime;
|
|
BOOL flag = (_scrollEventThrottle > 0 && _scrollEventThrottle < ti);
|
|
if (_allowNextScrollNoMatterWhat || flag) {
|
|
if (self.onScroll) {
|
|
self.onScroll([self scrollEventBody]);
|
|
}
|
|
_lastScrollDispatchTime = now;
|
|
_allowNextScrollNoMatterWhat = NO;
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidScroll:)]) {
|
|
[scrollViewListener scrollViewDidScroll:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView {
|
|
_allowNextScrollNoMatterWhat = YES; // Ensure next scroll event is recorded, regardless of throttle
|
|
if (self.onScrollBeginDrag) {
|
|
self.onScrollBeginDrag([self scrollEventBody]);
|
|
}
|
|
[[self rootView] cancelTouches];
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewWillBeginDragging:)]) {
|
|
[scrollViewListener scrollViewWillBeginDragging:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset {
|
|
// snapToInterval
|
|
// An alternative to enablePaging which allows setting custom stopping intervals,
|
|
// smaller than a full page size. Often seen in apps which feature horizonally
|
|
// scrolling items. snapToInterval does not enforce scrolling one interval at a time
|
|
// but guarantees that the scroll will stop at an interval point.
|
|
if (self.snapToInterval) {
|
|
CGFloat snapToIntervalF = (CGFloat)self.snapToInterval;
|
|
|
|
// Find which axis to snap
|
|
BOOL isHorizontal = (scrollView.contentSize.width > self.frame.size.width);
|
|
|
|
// What is the current offset?
|
|
CGFloat targetContentOffsetAlongAxis = isHorizontal ? targetContentOffset->x : targetContentOffset->y;
|
|
|
|
// Which direction is the scroll travelling?
|
|
CGPoint translation = [scrollView.panGestureRecognizer translationInView:scrollView];
|
|
CGFloat translationAlongAxis = isHorizontal ? translation.x : translation.y;
|
|
|
|
// Offset based on desired alignment
|
|
CGFloat frameLength = isHorizontal ? self.frame.size.width : self.frame.size.height;
|
|
CGFloat alignmentOffset = 0.0f;
|
|
if ([self.snapToAlignment isEqualToString:@"center"]) {
|
|
alignmentOffset = (frameLength * 0.5f) + (snapToIntervalF * 0.5f);
|
|
} else if ([self.snapToAlignment isEqualToString:@"end"]) {
|
|
alignmentOffset = frameLength;
|
|
}
|
|
|
|
// Pick snap point based on direction and proximity
|
|
NSInteger snapIndex = floor((targetContentOffsetAlongAxis + alignmentOffset) / snapToIntervalF);
|
|
BOOL isScrollingForward = translationAlongAxis < 0;
|
|
BOOL wasScrollingForward = translationAlongAxis == 0 && _lastNonZeroTranslationAlongAxis < 0;
|
|
if (isScrollingForward || wasScrollingForward) {
|
|
snapIndex = snapIndex + 1;
|
|
}
|
|
if (translationAlongAxis != 0) {
|
|
_lastNonZeroTranslationAlongAxis = translationAlongAxis;
|
|
}
|
|
CGFloat newTargetContentOffset = (snapIndex * snapToIntervalF) - alignmentOffset;
|
|
|
|
// Set new targetContentOffset
|
|
if (isHorizontal) {
|
|
targetContentOffset->x = newTargetContentOffset;
|
|
} else {
|
|
targetContentOffset->y = newTargetContentOffset;
|
|
}
|
|
}
|
|
|
|
if (self.onScrollEndDrag) {
|
|
NSDictionary *userData = @{
|
|
@"velocity": @ { @"x": @(velocity.x), @"y": @(velocity.y) },
|
|
@"targetContentOffset": @ { @"x": @(targetContentOffset->x), @"y": @(targetContentOffset->y) }
|
|
};
|
|
NSMutableDictionary *mutableBody = [NSMutableDictionary dictionaryWithDictionary:[self scrollEventBody]];
|
|
[mutableBody addEntriesFromDictionary:userData];
|
|
self.onScrollEndDrag(mutableBody);
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewWillEndDragging:withVelocity:targetContentOffset:)]) {
|
|
[scrollViewListener scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate {
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidEndDragging:willDecelerate:)]) {
|
|
[scrollViewListener scrollViewDidEndDragging:scrollView willDecelerate:decelerate];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view {
|
|
if (self.onScrollBeginDrag) {
|
|
self.onScrollBeginDrag([self scrollEventBody]);
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewWillBeginZooming:withView:)]) {
|
|
[scrollViewListener scrollViewWillBeginZooming:scrollView withView:view];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewDidZoom:(UIScrollView *)scrollView {
|
|
if (self.onScroll) {
|
|
self.onScroll([self scrollEventBody]);
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidZoom:)]) {
|
|
[scrollViewListener scrollViewDidZoom:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale {
|
|
if (self.onScrollEndDrag) {
|
|
self.onScrollEndDrag([self scrollEventBody]);
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidEndZooming:withView:atScale:)]) {
|
|
[scrollViewListener scrollViewDidEndZooming:scrollView withView:view atScale:scale];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewWillBeginDecelerating:(UIScrollView *)scrollView {
|
|
if (self.onMomentumScrollBegin) {
|
|
self.onMomentumScrollBegin([self scrollEventBody]);
|
|
}
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewWillBeginDecelerating:)]) {
|
|
[scrollViewListener scrollViewWillBeginDecelerating:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewDidEndDecelerating:(UIScrollView *)scrollView {
|
|
// Fire a final scroll event
|
|
_allowNextScrollNoMatterWhat = YES;
|
|
[self scrollViewDidScroll:scrollView];
|
|
|
|
if (self.onMomentumScrollEnd) {
|
|
self.onMomentumScrollEnd([self scrollEventBody]);
|
|
}
|
|
// Fire the end deceleration event
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidEndDecelerating:)]) {
|
|
[scrollViewListener scrollViewDidEndDecelerating:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView {
|
|
// Fire a final scroll event
|
|
_allowNextScrollNoMatterWhat = YES;
|
|
[self scrollViewDidScroll:scrollView];
|
|
|
|
NSDictionary *event = [self scrollEventBody];
|
|
|
|
if (self.onMomentumScrollEnd) {
|
|
self.onMomentumScrollEnd(event);
|
|
}
|
|
|
|
if (self.onScrollAnimationEnd) {
|
|
self.onScrollAnimationEnd(event);
|
|
}
|
|
// Fire the end deceleration event
|
|
for (NSObject<UIScrollViewDelegate> *scrollViewListener in _scrollListeners) {
|
|
if ([scrollViewListener respondsToSelector:@selector(scrollViewDidEndScrollingAnimation:)]) {
|
|
[scrollViewListener scrollViewDidEndScrollingAnimation:scrollView];
|
|
}
|
|
}
|
|
}
|
|
|
|
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView {
|
|
for (NSObject<UIScrollViewDelegate> *scrollListener in _scrollListeners) {
|
|
if ([scrollListener respondsToSelector:@selector(scrollViewShouldScrollToTop:)] && ![scrollListener scrollViewShouldScrollToTop:scrollView]) {
|
|
return NO;
|
|
}
|
|
}
|
|
return YES;
|
|
}
|
|
|
|
- (UIView *)viewForZoomingInScrollView:(__unused UIScrollView *)scrollView {
|
|
return _contentView;
|
|
}
|
|
|
|
- (HippyRootView *)rootView {
|
|
if (_rootView) {
|
|
return _rootView;
|
|
}
|
|
|
|
UIView *view = [self superview];
|
|
|
|
while (view && ![view isKindOfClass:[HippyRootView class]]) {
|
|
view = [view superview];
|
|
}
|
|
|
|
if ([view isKindOfClass:[HippyRootView class]]) {
|
|
_rootView = (HippyRootView *)view;
|
|
return _rootView;
|
|
} else
|
|
return nil;
|
|
}
|
|
|
|
- (void)didMoveToSuperview {
|
|
_rootView = nil;
|
|
}
|
|
|
|
#pragma mark - Setters
|
|
/**
|
|
* we need to cache scroll view's contentOffset.
|
|
* if scroll view is reused in list view cell, we can save its contentOffset in every cells,
|
|
* and set right contentOffset for each cell.
|
|
* resetting hippyTag meas scroll view is in reusing.
|
|
*/
|
|
- (void)setHippyTag:(NSNumber *)hippyTag {
|
|
if (![self.hippyTag isEqualToNumber:hippyTag]) {
|
|
if (self.hippyTag) {
|
|
NSString *offsetString = NSStringFromCGPoint(self.scrollView.contentOffset);
|
|
[_contentOffsetCache setObject:offsetString forKey:self.hippyTag];
|
|
}
|
|
[super setHippyTag:hippyTag];
|
|
}
|
|
}
|
|
|
|
- (void)setHorizontal:(BOOL)horizontal {
|
|
_horizontal = horizontal;
|
|
if ([self needsLayoutForRTL]) {
|
|
_scrollView.transform = CGAffineTransformMakeRotation(M_PI);
|
|
}
|
|
}
|
|
|
|
- (CGSize)_calculateViewportSize {
|
|
CGSize viewportSize = self.bounds.size;
|
|
if (_automaticallyAdjustContentInsets) {
|
|
UIEdgeInsets contentInsets = [HippyView contentInsetsForView:self];
|
|
viewportSize = CGSizeMake(
|
|
self.bounds.size.width - contentInsets.left - contentInsets.right, self.bounds.size.height - contentInsets.top - contentInsets.bottom);
|
|
}
|
|
return viewportSize;
|
|
}
|
|
|
|
- (CGPoint)calculateOffsetForContentSize:(CGSize)newContentSize {
|
|
CGPoint oldOffset = _scrollView.contentOffset;
|
|
CGPoint newOffset = oldOffset;
|
|
|
|
CGSize oldContentSize = _scrollView.contentSize;
|
|
CGSize viewportSize = [self _calculateViewportSize];
|
|
|
|
BOOL fitsinViewportY = oldContentSize.height <= viewportSize.height && newContentSize.height <= viewportSize.height;
|
|
if (newContentSize.height < oldContentSize.height && !fitsinViewportY) {
|
|
CGFloat offsetHeight = oldOffset.y + viewportSize.height;
|
|
if (oldOffset.y < 0) {
|
|
// overscrolled on top, leave offset alone
|
|
} else if (offsetHeight > oldContentSize.height) {
|
|
// overscrolled on the bottom, preserve overscroll amount
|
|
newOffset.y = MAX(0, oldOffset.y - (oldContentSize.height - newContentSize.height));
|
|
} else if (offsetHeight > newContentSize.height) {
|
|
// offset falls outside of bounds, scroll back to end of list
|
|
newOffset.y = MAX(0, newContentSize.height - viewportSize.height);
|
|
}
|
|
}
|
|
|
|
BOOL fitsinViewportX = oldContentSize.width <= viewportSize.width && newContentSize.width <= viewportSize.width;
|
|
if (newContentSize.width < oldContentSize.width && !fitsinViewportX) {
|
|
CGFloat offsetHeight = oldOffset.x + viewportSize.width;
|
|
if (oldOffset.x < 0) {
|
|
// overscrolled at the beginning, leave offset alone
|
|
} else if (offsetHeight > oldContentSize.width && newContentSize.width > viewportSize.width) {
|
|
// overscrolled at the end, preserve overscroll amount as much as possible
|
|
newOffset.x = MAX(0, oldOffset.x - (oldContentSize.width - newContentSize.width));
|
|
} else if (offsetHeight > newContentSize.width) {
|
|
// offset falls outside of bounds, scroll back to end
|
|
newOffset.x = MAX(0, newContentSize.width - viewportSize.width);
|
|
}
|
|
}
|
|
|
|
// all other cases, offset doesn't change
|
|
return newOffset;
|
|
}
|
|
|
|
/**
|
|
* Once you set the `contentSize`, to a nonzero value, it is assumed to be
|
|
* managed by you, and we'll never automatically compute the size for you,
|
|
* unless you manually reset it back to {0, 0}
|
|
*/
|
|
- (CGSize)contentSize {
|
|
if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
|
|
return _contentSize;
|
|
} else if (!_contentView) {
|
|
return CGSizeZero;
|
|
} else {
|
|
CGSize singleSubviewSize = _contentView.frame.size;
|
|
// CGPoint singleSubviewPosition = _contentView.frame.origin;
|
|
// return (CGSize) { singleSubviewSize.width + singleSubviewPosition.x, singleSubviewSize.height + singleSubviewPosition.y };
|
|
return singleSubviewSize;
|
|
}
|
|
}
|
|
|
|
- (void)hippyBridgeDidFinishTransaction {
|
|
CGSize contentSize = self.contentSize;
|
|
if (!CGSizeEqualToSize(_scrollView.contentSize, contentSize)) {
|
|
// When contentSize is set manually, ScrollView internals will reset
|
|
// contentOffset to {0, 0}. Since we potentially set contentSize whenever
|
|
// anything in the ScrollView updates, we workaround this issue by manually
|
|
// adjusting contentOffset whenever this happens
|
|
CGPoint newOffset = [self calculateOffsetForContentSize:contentSize];
|
|
_scrollView.contentSize = contentSize;
|
|
_scrollView.contentOffset = newOffset;
|
|
}
|
|
}
|
|
|
|
- (void)didSetProps:(NSArray<NSString *> *)changedProps {
|
|
if ([changedProps containsObject:@"contentOffset"]) {
|
|
_didSetContentOffset = YES;
|
|
}
|
|
}
|
|
|
|
- (BOOL)needsLayoutForRTL {
|
|
return NSWritingDirectionRightToLeft == [[HippyI18nUtils sharedInstance] writingDirectionForCurrentAppLanguage] && _horizontal;
|
|
}
|
|
|
|
// Note: setting several properties of UIScrollView has the effect of
|
|
// resetting its contentOffset to {0, 0}. To prevent this, we generate
|
|
// setters here that will record the contentOffset beforehand, and
|
|
// restore it after the property has been set.
|
|
|
|
#define HIPPY_SET_AND_PRESERVE_OFFSET(setter, getter, type) \
|
|
-(void)setter : (type)value { \
|
|
CGPoint contentOffset = _scrollView.contentOffset; \
|
|
[_scrollView setter:value]; \
|
|
_scrollView.contentOffset = contentOffset; \
|
|
} \
|
|
-(type)getter { \
|
|
return [_scrollView getter]; \
|
|
}
|
|
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setAlwaysBounceHorizontal, alwaysBounceHorizontal, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setAlwaysBounceVertical, alwaysBounceVertical, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setBounces, bounces, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setBouncesZoom, bouncesZoom, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setCanCancelContentTouches, canCancelContentTouches, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setDecelerationRate, decelerationRate, CGFloat)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setDirectionalLockEnabled, isDirectionalLockEnabled, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setIndicatorStyle, indicatorStyle, UIScrollViewIndicatorStyle)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setKeyboardDismissMode, keyboardDismissMode, UIScrollViewKeyboardDismissMode)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setMaximumZoomScale, maximumZoomScale, CGFloat)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setMinimumZoomScale, minimumZoomScale, CGFloat)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setScrollEnabled, isScrollEnabled, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setPagingEnabled, isPagingEnabled, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setScrollsToTop, scrollsToTop, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setShowsHorizontalScrollIndicator, showsHorizontalScrollIndicator, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setShowsVerticalScrollIndicator, showsVerticalScrollIndicator, BOOL)
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setZoomScale, zoomScale, CGFloat);
|
|
HIPPY_SET_AND_PRESERVE_OFFSET(setScrollIndicatorInsets, scrollIndicatorInsets, UIEdgeInsets);
|
|
|
|
@end
|