Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Direction to Scroll #33

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
23 changes: 19 additions & 4 deletions Classes/UIScrollView+InfiniteScroll.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@

NS_ASSUME_NONNULL_BEGIN

typedef NS_ENUM(NSInteger, UIScrollViewInfiniteScrollDirection) {
UIScrollViewInfiniteScrollDirectionTop = 0,
Copy link
Owner

@pronebird pronebird Apr 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upward direction is default in this PR. Would be great to maintain the existing behavior which is downward "refresh". This is better achieved by moving infinite scroll direction to state object and then resetting it to bottom during -init.

UIScrollViewInfiniteScrollDirectionBottom = 1
};

@interface UIScrollView (InfiniteScroll)

/**
* Indicates whether inifinte scrol should be on top or bottom
*/
@property (nonatomic, assign) UIScrollViewInfiniteScrollDirection infiniteScrollDirection;

/**
* Flag that indicates whether infinite scroll is animating
*/
Expand All @@ -36,7 +46,7 @@ NS_ASSUME_NONNULL_BEGIN
/**
* Infinite indicator view
*
* You can set your own custom view instead of default activity indicator,
* You can set your own custom view instead of default activity indicator,
* make sure it implements methods below:
*
* * `- (void)startAnimating`
Expand All @@ -51,6 +61,11 @@ NS_ASSUME_NONNULL_BEGIN
*/
@property (nonatomic) CGFloat infiniteScrollIndicatorMargin;

/**
* A flag that indicates whether validate if content is higher that view height. Default NO.
*/
@property (nonatomic) BOOL allowTriggerOnUnfilledContent;

/**
* Setup infinite scroll handler
*
Expand All @@ -61,7 +76,7 @@ NS_ASSUME_NONNULL_BEGIN
/**
* Unregister infinite scroll
*/
- (void)removeInfiniteScroll;
- (void)removeInfiniteScroll_;

/**
* Finish infinite scroll animations
Expand All @@ -71,15 +86,15 @@ NS_ASSUME_NONNULL_BEGIN
*
* @param handler a completion block handler called when animation finished
*/
- (void)finishInfiniteScrollWithCompletion:(nullable void(^)(__pb_kindof(UIScrollView *) scrollView))handler;
- (void)finishInfiniteScroll:(BOOL)animated completion:(nullable void(^)(__pb_kindof(UIScrollView *) scrollView))handler;

/**
* Finish infinite scroll animations
*
* You must call this method from your infinite scroll handler to finish all
* animations properly and reset infinite scroll state
*/
- (void)finishInfiniteScroll;
- (void)finishInfiniteScroll:(BOOL)animated;
Copy link
Owner

@pronebird pronebird Apr 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be better to add DEPRECATED attribute and keep the method around for a while so others could adjust.


@end

Expand Down
164 changes: 126 additions & 38 deletions Classes/UIScrollView+InfiniteScroll.m
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ static void PBSwizzleMethod(Class c, SEL original, SEL alternate) {

// Keys for values in associated dictionary
static const void *kPBInfiniteScrollStateKey = &kPBInfiniteScrollStateKey;
static const void *kPBInfiniteScrollDirectionKey = &kPBInfiniteScrollDirectionKey;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason why it cannot reside within pb_infiniteScrollState?


/**
* Infinite scroll state class.
Expand Down Expand Up @@ -67,6 +68,12 @@ @interface _PBInfiniteScrollState : NSObject
*/
@property CGFloat extraBottomInset;

/**
* Extra padding to push indicator view below view bounds.
* Used in case when content size is smaller than view bounds
*/
@property CGFloat extraTopInset;

/**
* Indicator view inset.
* Essentially is equal to indicator view height.
Expand All @@ -78,6 +85,11 @@ @interface _PBInfiniteScrollState : NSObject
*/
@property CGFloat indicatorMargin;

/**
* A flag that indicates whether validate if content is higher that view height. Default NO.
*/
@property (nonatomic) BOOL allowTriggerOnUnfilledContent;

/**
* Infinite scroll handler block
*/
Expand Down Expand Up @@ -120,6 +132,15 @@ @implementation UIScrollView (InfiniteScroll)

#pragma mark - Public methods

- (void)setInfiniteScrollDirection:(UIScrollViewInfiniteScrollDirection)infiniteScrollDirection {
objc_setAssociatedObject(self, kPBInfiniteScrollDirectionKey, @(infiniteScrollDirection), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (UIScrollViewInfiniteScrollDirection)infiniteScrollDirection {
NSNumber *direction = objc_getAssociatedObject(self, kPBInfiniteScrollDirectionKey);
return direction.integerValue;
}

- (BOOL)isAnimatingInfiniteScroll {
return self.pb_infiniteScrollState.loading;
}
Expand All @@ -143,7 +164,7 @@ - (void)addInfiniteScrollWithHandler:(void(^)(__pb_kindof(UIScrollView *) scroll
state.initialized = YES;
}

- (void)removeInfiniteScroll {
- (void)removeInfiniteScroll_ {
_PBInfiniteScrollState *state = self.pb_infiniteScrollState;

// Ignore multiple calls to remove infinite scroll
Expand All @@ -162,13 +183,13 @@ - (void)removeInfiniteScroll {
self.pb_infiniteScrollState.initialized = NO;
}

- (void)finishInfiniteScroll {
[self finishInfiniteScrollWithCompletion:nil];
- (void)finishInfiniteScroll:(BOOL)animated {
[self finishInfiniteScroll:animated completion:nil];
}

- (void)finishInfiniteScrollWithCompletion:(nullable void(^)(__pb_kindof(UIScrollView *) scrollView))handler {
- (void)finishInfiniteScroll:(BOOL)animated completion:(nullable void(^)(__pb_kindof(UIScrollView *) scrollView))handler {
if(self.pb_infiniteScrollState.loading) {
[self pb_stopAnimatingInfiniteScrollWithCompletion:handler];
[self pb_stopAnimatingInfiniteScroll:animated completion:handler];
}
}

Expand All @@ -189,7 +210,7 @@ - (UIActivityIndicatorViewStyle)infiniteScrollIndicatorStyle {
- (void)setInfiniteScrollIndicatorView:(UIView *)indicatorView {
// make sure indicator is initially hidden
indicatorView.hidden = YES;

self.pb_infiniteScrollState.indicatorView = indicatorView;
}

Expand All @@ -205,11 +226,19 @@ - (CGFloat)infiniteScrollIndicatorMargin {
return self.pb_infiniteScrollState.indicatorMargin;
}

- (void)setAllowTriggerOnUnfilledContent:(BOOL)allowTriggerOnUnfilledContent {
self.pb_infiniteScrollState.allowTriggerOnUnfilledContent = allowTriggerOnUnfilledContent;
}

- (BOOL)allowTriggerOnUnfilledContent {
return self.pb_infiniteScrollState.allowTriggerOnUnfilledContent;
}

#pragma mark - Private dynamic properties

- (_PBInfiniteScrollState *)pb_infiniteScrollState {
_PBInfiniteScrollState *state = objc_getAssociatedObject(self, kPBInfiniteScrollStateKey);

if(!state) {
state = [[_PBInfiniteScrollState alloc] init];

Expand Down Expand Up @@ -275,12 +304,27 @@ - (void)pb_setContentSize:(CGSize)contentSize {
* @return CGFloat
*/
- (CGFloat)pb_clampContentSizeToFitVisibleBounds:(CGSize)contentSize {

CGFloat inset = self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop ? [self pb_originalTopInset] + self.contentInset.bottom : [self pb_originalBottomInset] + self.contentInset.top;
Copy link
Owner

@pronebird pronebird Apr 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Long ternary operations especially when doing math can be misleading or confusing. if-else or switch-case would be better.



// Find minimum content height. Only original insets are used in calculation.
CGFloat minHeight = self.bounds.size.height - self.contentInset.top - [self pb_originalBottomInset];

CGFloat minHeight = self.bounds.size.height - inset;
return MAX(contentSize.height, minHeight);
}

/**
* Returns top inset without extra padding and indicator padding.
*
* @return CGFloat
*/
- (CGFloat)pb_originalTopInset {
_PBInfiniteScrollState *state = self.pb_infiniteScrollState;

return self.contentInset.top - state.extraTopInset - state.indicatorInset;
}

/**
* Returns bottom inset without extra padding and indicator padding.
*
Expand Down Expand Up @@ -345,9 +389,11 @@ - (CGFloat)pb_infiniteIndicatorRowHeight {
*/
- (void)pb_positionInfiniteScrollIndicatorWithContentSize:(CGSize)contentSize {
UIView *activityIndicator = [self pb_getOrCreateActivityIndicatorView];
CGFloat contentHeight = [self pb_clampContentSizeToFitVisibleBounds:contentSize];
CGFloat contentHeight = self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop ? 0.0f : [self pb_clampContentSizeToFitVisibleBounds:contentSize];
CGFloat indicatorRowHeight = [self pb_infiniteIndicatorRowHeight];
CGPoint center = CGPointMake(contentSize.width * 0.5, contentHeight + indicatorRowHeight * 0.5);
CGFloat sign = self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop ? -1 : 1;

CGPoint center = CGPointMake(contentSize.width * 0.5, sign * (contentHeight + indicatorRowHeight * 0.5));

if(!CGPointEqualToPoint(activityIndicator.center, center)) {
activityIndicator.center = center;
Expand Down Expand Up @@ -375,23 +421,41 @@ - (void)pb_startAnimatingInfiniteScroll {

UIEdgeInsets contentInset = self.contentInset;

// Make a room to accommodate indicator view
contentInset.bottom += indicatorInset;

// We have to pad scroll view when content height is smaller than view bounds.
// This will guarantee that indicator view appears at the very bottom of scroll view.
CGFloat adjustedContentHeight = [self pb_clampContentSizeToFitVisibleBounds:self.contentSize];
CGFloat extraBottomInset = adjustedContentHeight - self.contentSize.height;
CGFloat extraInset = adjustedContentHeight - self.contentSize.height;

// Make a room to accommodate indicator view
if (self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop) {

//
contentInset.top += indicatorInset;

// Add empty space padding
contentInset.top += 0.0f;

//
state.extraTopInset = 0.0f;
}

// Add empty space padding
contentInset.bottom += extraBottomInset;
// bottom
else {

//
contentInset.bottom += indicatorInset;

// Add empty space padding
contentInset.bottom += extraInset;

//
state.extraBottomInset = extraInset;
}

// Save indicator view inset
state.indicatorInset = indicatorInset;

// Save extra inset
state.extraBottomInset = extraBottomInset;

// Update infinite scroll state
state.loading = YES;

Expand All @@ -401,7 +465,7 @@ - (void)pb_startAnimatingInfiniteScroll {
[self pb_scrollToInfiniteIndicatorIfNeeded];
}
}];

TRACE(@"Start animating.");
}

Expand All @@ -410,25 +474,38 @@ - (void)pb_startAnimatingInfiniteScroll {
*
* @param handler a completion handler
*/
- (void)pb_stopAnimatingInfiniteScrollWithCompletion:(nullable void(^)(id scrollView))handler {
- (void)pb_stopAnimatingInfiniteScroll:(BOOL)animated completion:(nullable void(^)(id scrollView))handler {
_PBInfiniteScrollState *state = self.pb_infiniteScrollState;
UIView *activityIndicator = self.infiniteScrollIndicatorView;
UIEdgeInsets contentInset = self.contentInset;

// Remove row height inset
contentInset.bottom -= state.indicatorInset;
// top
if (self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop) {

contentInset.top -= state.indicatorInset;

// Remove extra inset added to pad infinite scroll
contentInset.top -= state.extraTopInset;
}

// Remove extra inset added to pad infinite scroll
contentInset.bottom -= state.extraBottomInset;
// bottom
else {
// Remove row height inset
contentInset.bottom -= state.indicatorInset;

// Remove extra inset added to pad infinite scroll
contentInset.bottom -= state.extraBottomInset;
}

// Reset indicator view inset
state.indicatorInset = 0;

// Reset extra bottom inset
state.extraBottomInset = 0;
state.extraTopInset = 0;

// Animate content insets
[self pb_setScrollViewContentInset:contentInset animated:YES completion:^(BOOL finished) {
[self pb_setScrollViewContentInset:contentInset animated:animated completion:^(BOOL finished) {
// Curtain is closing they're throwing roses at my feet
if([activityIndicator respondsToSelector:@selector(stopAnimating)]) {
[activityIndicator performSelector:@selector(stopAnimating)];
Expand Down Expand Up @@ -461,18 +538,22 @@ - (void)pb_stopAnimatingInfiniteScrollWithCompletion:(nullable void(^)(id scroll
- (void)pb_scrollViewDidScroll:(CGPoint)contentOffset {
_PBInfiniteScrollState *state = self.pb_infiniteScrollState;

CGFloat contentHeight = [self pb_clampContentSizeToFitVisibleBounds:self.contentSize];

// The lower bound when infinite scroll should kick in
CGFloat actionOffset = contentHeight - self.bounds.size.height + [self pb_originalBottomInset];

// Disable infinite scroll when scroll view is empty
// Default UITableView reports height = 1 on empty tables
BOOL hasActualContent = (self.contentSize.height > 1);
CGFloat actionOffset = 0;

// is there any content?
if(!hasActualContent) {
return;
if (self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionBottom) {
CGFloat contentHeight = [self pb_clampContentSizeToFitVisibleBounds:self.contentSize];

// The lower bound when infinite scroll should kick in
actionOffset = contentHeight - self.bounds.size.height + [self pb_originalBottomInset];

// Disable infinite scroll when scroll view is empty
// Default UITableView reports height = 1 on empty tables
BOOL hasActualContent = (self.contentSize.height > 1);

// is there any content?
if(!hasActualContent) {
return;
}
}

// is user initiated?
Expand All @@ -485,7 +566,10 @@ - (void)pb_scrollViewDidScroll:(CGPoint)contentOffset {
return;
}

if(contentOffset.y > actionOffset) {
BOOL validContentHeight = !self.allowTriggerOnUnfilledContent ? self.contentSize.height > CGRectGetHeight(self.frame) : YES;
BOOL animate = self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionBottom ? contentOffset.y > actionOffset : contentOffset.y < 0 && validContentHeight;

if(animate) {
TRACE(@"Action.");

[self pb_startAnimatingInfiniteScroll];
Expand All @@ -499,6 +583,10 @@ - (void)pb_scrollViewDidScroll:(CGPoint)contentOffset {
* Scrolls down to activity indicator position if activity indicator is partially visible
*/
- (void)pb_scrollToInfiniteIndicatorIfNeeded {

// top
if (self.infiniteScrollDirection == UIScrollViewInfiniteScrollDirectionTop) return;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indicator can be half visible, this is when we need to scroll up or down to reveal or hide it based on scroll position. It's similar to how search bar works in table views.


// do not interfere with user
if([self isDragging]) {
return;
Expand Down