// // TOScrollBar.m // // Copyright 2016-2017 Timothy Oliver. All rights reserved. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to // deal in the Software without restriction, including without limitation the // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, // WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR // IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. #import "TOScrollBar.h" #import "UIScrollView+TOScrollBar.h" #import "TOScrollBarGestureRecognizer.h" /** Default values for the scroll bar */ static const CGFloat kTOScrollBarTrackWidth = 2.0f; // The default width of the scrollable space indicator static const CGFloat kTOScrollBarHandleWidth = 4.0f; // The default width of the handle control static const CGFloat kTOScrollBarEdgeInset = 7.5f; // The distance from the edge of the view to the center of the track static const CGFloat kTOScrollBarHandleMinHeight = 64.0f; // The minimum usable size to which the handle can shrink static const CGFloat kTOScrollBarWidth = 20.0f; // The width of this control (44 is minimum recommended tapping space) TWS how handleWidth static const CGFloat kTOScrollBarVerticalPadding = 10.0f; // The default padding at the top and bottom of the view static const CGFloat kTOScrollBarMinimumContentScale = 5.0f; // The minimum scale of the content view before showing the scroll view is necessary /************************************************************************/ // A struct to hold the scroll view's previous state before this bar was applied struct TOScrollBarScrollViewState { BOOL showsVerticalScrollIndicator; }; typedef struct TOScrollBarScrollViewState TOScrollBarScrollViewState; /************************************************************************/ // Private interface exposure for scroll view category @interface UIScrollView () //TOScrollBar - (void)setTo_scrollBar:(TOScrollBar *)scrollBar; @end /************************************************************************/ @interface TOScrollBar () { TOScrollBarScrollViewState _scrollViewState; } @property (nonatomic, weak, readwrite) UIScrollView *scrollView; // The parent scroll view in which we belong @property (nonatomic, assign) BOOL userHidden; // View was explicitly hidden by the user as opposed to us @property (nonatomic, strong) UIImageView *trackView; // The track indicating the scrollable distance @property (nonatomic, strong) UIImageView *handleView; // The handle that may be dragged in the scroll bar @property (nonatomic, assign, readwrite) BOOL dragging; // The user is presently dragging the handle @property (nonatomic, assign) CGFloat yOffset; // The offset from the center of the thumb @property (nonatomic, assign) CGFloat originalYOffset; // The original placement of the scroll bar when the user started dragging @property (nonatomic, assign) CGFloat originalHeight; // The original height of the scroll bar when the user started dragging @property (nonatomic, assign) CGFloat originalTopInset; // The original safe area inset of the scroll bar when the user started dragging @property (nonatomic, assign) CGFloat horizontalOffset; // The horizontal offset when the edge inset is too small for the touch region @property (nonatomic, assign) BOOL disabled; // Disabled when there's not enough scroll content to merit showing this @property (nonatomic, strong) UIImpactFeedbackGenerator *feedbackGenerator; // Taptic feedback for iPhone 7 and above @property (nonatomic, strong) TOScrollBarGestureRecognizer *gestureRecognizer; // Our custom recognizer for handling user interactions with the scroll bar @end /************************************************************************/ @implementation TOScrollBar #pragma mark - Class Creation - - (instancetype)initWithStyle:(TOScrollBarStyle)style { if (self = [super initWithFrame:CGRectZero]) { _style = style; [self setUpInitialProperties]; } return self; } - (instancetype)initWithFrame:(CGRect)frame { if (self = [super initWithFrame:frame]) { [self setUpInitialProperties]; } return self; } - (instancetype)initWithCoder:(NSCoder *)aDecoder { if (self = [super initWithCoder:aDecoder]) { [self setUpInitialProperties]; } return self; } #pragma mark - Set-up - - (void)setUpInitialProperties { _trackWidth = kTOScrollBarTrackWidth; _handleWidth = kTOScrollBarHandleWidth; _edgeInset = kTOScrollBarEdgeInset; _handleMinimiumHeight = kTOScrollBarHandleMinHeight; _minimumContentHeightScale = kTOScrollBarMinimumContentScale; _verticalInset = UIEdgeInsetsMake(kTOScrollBarVerticalPadding, 0.0f, kTOScrollBarVerticalPadding, 0.0f); _feedbackGenerator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; _gestureRecognizer = [[TOScrollBarGestureRecognizer alloc] initWithTarget:self action:@selector(scrollBarGestureRecognized:)]; } - (void)setUpViews { if (self.trackView || self.handleView) { return; } self.backgroundColor = [UIColor clearColor]; // Create and add the track view self.trackView = [[UIImageView alloc] initWithImage:[TOScrollBar verticalCapsuleImageWithWidth:self.trackWidth]]; [self addSubview:self.trackView]; // Add the handle view self.handleView = [[UIImageView alloc] initWithImage:[TOScrollBar verticalCapsuleImageWithWidth:self.handleWidth]]; [self addSubview:self.handleView]; // Add the initial styling [self configureViewsForStyle:self.style]; // Add gesture recognizer [self addGestureRecognizer:self.gestureRecognizer]; } - (void)configureViewsForStyle:(TOScrollBarStyle)style { BOOL dark = (style == TOScrollBarStyleDark); CGFloat whiteColor = 0.0f; if (dark) { whiteColor = 1.0f; } self.trackView.tintColor = [UIColor colorWithWhite:whiteColor alpha:0.1f]; } - (void)dealloc { [self restoreScrollView:self.scrollView]; } - (void)configureScrollView:(UIScrollView *)scrollView { if (scrollView == nil) { return; } // Make a copy of the scroll view's state and then configure _scrollViewState.showsVerticalScrollIndicator = self.scrollView.showsVerticalScrollIndicator; scrollView.showsVerticalScrollIndicator = NO; //Key-value Observers [scrollView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil]; [scrollView addObserver:self forKeyPath:@"contentSize" options:NSKeyValueObservingOptionNew context:nil]; } - (void)restoreScrollView:(UIScrollView *)scrollView { if (scrollView == nil) { return; } // Restore the scroll view's state scrollView.showsVerticalScrollIndicator = _scrollView.showsVerticalScrollIndicator; // Remove the observers [scrollView removeObserver:self forKeyPath:@"contentOffset"]; [scrollView removeObserver:self forKeyPath:@"contentSize"]; } - (void)willMoveToSuperview:(UIView *)newSuperview { [super willMoveToSuperview:newSuperview]; [self setUpViews]; } #pragma mark - Content Layout - - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { [self updateStateForScrollView]; if (self.hidden) { return; } [self layoutInScrollView]; [self setNeedsLayout]; } - (CGFloat)heightOfHandleForContentSize { if (_scrollView == nil) { return _handleMinimiumHeight; } CGFloat heightRatio = self.scrollView.frame.size.height / self.scrollView.contentSize.height; CGFloat height = self.frame.size.height * heightRatio; return MAX(floorf(height), _handleMinimiumHeight); } - (void)updateStateForScrollView { CGRect frame = _scrollView.frame; CGSize contentSize = _scrollView.contentSize; self.disabled = (contentSize.height / frame.size.height) < _minimumContentHeightScale; [self setHidden:(self.disabled || self.userHidden) animated:NO]; } - (void)layoutInScrollView { CGRect scrollViewFrame = _scrollView.frame; UIEdgeInsets insets = _scrollView.contentInset; CGPoint contentOffset = _scrollView.contentOffset; CGFloat halfWidth = (kTOScrollBarWidth * 0.5f); if (@available(iOS 11.0, *)) { insets = _scrollView.adjustedContentInset; } // Contract the usable space by the scroll view's content inset (eg navigation/tool bars) scrollViewFrame.size.height -= (insets.top + insets.bottom); CGFloat largeTitleDelta = 0.0f; if (_insetForLargeTitles) { largeTitleDelta = fabs(MIN(insets.top + contentOffset.y, 0.0f)); } // Work out the final height be further contracting by the padding CGFloat height = (scrollViewFrame.size.height - (_verticalInset.top + _verticalInset.bottom)) - largeTitleDelta; // Work out how much we have to offset the track by to make sure all of the parent view // is visible at the edge of the screen (Or else we'll be unable to tap properly) CGFloat horizontalOffset = halfWidth - _edgeInset; self.horizontalOffset = (horizontalOffset > 0.0f) ? horizontalOffset : 0.0f; // Work out the frame for the scroll view CGRect frame = CGRectZero; // Size frame.size.width = kTOScrollBarWidth; frame.size.height = (_dragging ? _originalHeight : height); // Horizontal placement frame.origin.x = scrollViewFrame.size.width - (_edgeInset + halfWidth); if (@available(iOS 11.0, *)) { frame.origin.x -= _scrollView.safeAreaInsets.right; } frame.origin.x = MIN(frame.origin.x, scrollViewFrame.size.width - kTOScrollBarWidth); // Vertical placement in scroll view if (_dragging) { frame.origin.y = _originalYOffset; } else { frame.origin.y = _verticalInset.top; frame.origin.y += insets.top; frame.origin.y += largeTitleDelta; } frame.origin.y += contentOffset.y; // Set the frame self.frame = frame; // Bring the scroll bar to the front in case other subviews were subsequently added over it [self.superview bringSubviewToFront:self]; } - (void)layoutSubviews { CGRect frame = self.frame; // The frame of the track CGRect trackFrame = CGRectZero; trackFrame.size.width = _trackWidth; trackFrame.size.height = frame.size.height; trackFrame.origin.x = ceilf(((frame.size.width - _trackWidth) * 0.5f) + _horizontalOffset); self.trackView.frame = CGRectIntegral(trackFrame); // Don't handle automatic layout when dragging; we'll do that manually elsewhere if (self.dragging || self.disabled) { return; } // The frame of the handle CGRect handleFrame = CGRectZero; handleFrame.size.width = _handleWidth; handleFrame.size.height = [self heightOfHandleForContentSize]; handleFrame.origin.x = ceilf(((frame.size.width - _handleWidth) * 0.5f) + _horizontalOffset); // Work out the y offset of the handle UIEdgeInsets contentInset = _scrollView.contentInset; if (@available(iOS 11.0, *)) { contentInset = _scrollView.safeAreaInsets; } CGPoint contentOffset = _scrollView.contentOffset; CGSize contentSize = _scrollView.contentSize; CGRect scrollViewFrame = _scrollView.frame; CGFloat scrollableHeight = (contentSize.height + contentInset.top + contentInset.bottom) - scrollViewFrame.size.height; CGFloat scrollProgress = (contentOffset.y + contentInset.top) / scrollableHeight; handleFrame.origin.y = (frame.size.height - handleFrame.size.height) * scrollProgress; // If the scroll view expanded beyond its scrollable range, shrink the handle to match the rubber band effect if (contentOffset.y < -contentInset.top) { // The top handleFrame.size.height -= (-contentOffset.y - contentInset.top); handleFrame.size.height = MAX(handleFrame.size.height, (_trackWidth * 2 + 2)); } else if (contentOffset.y + scrollViewFrame.size.height > contentSize.height + contentInset.bottom) { // The bottom CGFloat adjustedContentOffset = contentOffset.y + scrollViewFrame.size.height; CGFloat delta = adjustedContentOffset - (contentSize.height + contentInset.bottom); handleFrame.size.height -= delta; handleFrame.size.height = MAX(handleFrame.size.height, (_trackWidth * 2 + 2)); handleFrame.origin.y = frame.size.height - handleFrame.size.height; } // Clamp to the bounds of the frame handleFrame.origin.y = MAX(handleFrame.origin.y, 0.0f); handleFrame.origin.y = MIN(handleFrame.origin.y, (frame.size.height - handleFrame.size.height)); self.handleView.frame = handleFrame; } - (void)setScrollYOffsetForHandleYOffset:(CGFloat)yOffset animated:(BOOL)animated { CGFloat heightRange = _trackView.frame.size.height - _handleView.frame.size.height; yOffset = MAX(0.0f, yOffset); yOffset = MIN(heightRange, yOffset); CGFloat positionRatio = yOffset / heightRange; CGRect frame = _scrollView.frame; UIEdgeInsets inset = _scrollView.contentInset; CGSize contentSize = _scrollView.contentSize; if (@available(iOS 11.0, *)) { inset = _scrollView.adjustedContentInset; } inset.top = _originalTopInset; CGFloat totalScrollSize = (contentSize.height + inset.top + inset.bottom) - frame.size.height; CGFloat scrollOffset = totalScrollSize * positionRatio; scrollOffset -= inset.top; CGPoint contentOffset = _scrollView.contentOffset; contentOffset.y = scrollOffset; // Animate to help coax the large title navigation bar to behave if (@available(iOS 11.0, *)) { [UIView animateWithDuration:animated ? 0.1f : 0.00001f animations:^{ [self.scrollView setContentOffset:contentOffset animated:NO]; }]; } else { [self.scrollView setContentOffset:contentOffset animated:NO]; } } #pragma mark - Scroll View Integration - - (void)addToScrollView:(UIScrollView *)scrollView { if (scrollView == self.scrollView) { return; } // Restore the previous scroll view [self restoreScrollView:self.scrollView]; // Assign the new scroll view self.scrollView = scrollView; // Apply the observers/settings to the new scroll view [self configureScrollView:scrollView]; // Add the scroll bar to the scroll view's content view [self.scrollView addSubview:self]; // Add ourselves as a property of the scroll view [self.scrollView setTo_scrollBar:self]; // Begin layout [self layoutInScrollView]; } - (void)removeFromScrollView { [self restoreScrollView:self.scrollView]; [self removeFromSuperview]; [self.scrollView setTo_scrollBar:nil]; self.scrollView = nil; } - (UIEdgeInsets)adjustedTableViewSeparatorInsetForInset:(UIEdgeInsets)inset { inset.right = _edgeInset * 2.0f; return inset; } - (UIEdgeInsets)adjustedTableViewCellLayoutMarginsForMargins:(UIEdgeInsets)layoutMargins manualOffset:(CGFloat)offset { layoutMargins.right = (_edgeInset * 2.0f) + 15.0f; // Magic system number is 20, but we can't infer that from here on time layoutMargins.right += offset; return layoutMargins; } #pragma mark - User Interaction - - (void)scrollBarGestureRecognized:(TOScrollBarGestureRecognizer *)recognizer { CGPoint touchPoint = [recognizer locationInView:self]; switch (recognizer.state) { case UIGestureRecognizerStateBegan: [self gestureBeganAtPoint:touchPoint]; break; case UIGestureRecognizerStateChanged: [self gestureMovedToPoint:touchPoint]; break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: [self gestureEnded]; break; default: break; } } - (void)gestureBeganAtPoint:(CGPoint)touchPoint { if (self.disabled) { return; } // Warm-up the feedback generator [_feedbackGenerator prepare]; self.scrollView.scrollEnabled = NO; self.dragging = YES; // Capture the original position self.originalHeight = self.frame.size.height; self.originalYOffset = self.frame.origin.y - self.scrollView.contentOffset.y; if (@available(iOS 11.0, *)) { self.originalTopInset = _scrollView.adjustedContentInset.top; } else { self.originalTopInset = _scrollView.contentInset.top; } // Check if the user tapped inside the handle CGRect handleFrame = self.handleView.frame; if (touchPoint.y > (handleFrame.origin.y - 20) && touchPoint.y < handleFrame.origin.y + (handleFrame.size.height + 20)) { self.yOffset = (touchPoint.y - handleFrame.origin.y); return; } if (!self.handleExclusiveInteractionEnabled) { // User tapped somewhere else, animate the handle to that point CGFloat halfHeight = (handleFrame.size.height * 0.5f); CGFloat destinationYOffset = touchPoint.y - halfHeight; destinationYOffset = MAX(0.0f, destinationYOffset); destinationYOffset = MIN(self.frame.size.height - halfHeight, destinationYOffset); self.yOffset = (touchPoint.y - destinationYOffset); handleFrame.origin.y = destinationYOffset; [UIView animateWithDuration:0.2f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:0.1f options:UIViewAnimationOptionBeginFromCurrentState animations:^{ self.handleView.frame = handleFrame; } completion:nil]; [self setScrollYOffsetForHandleYOffset:floorf(destinationYOffset) animated:NO]; } } - (void)gestureMovedToPoint:(CGPoint)touchPoint { if (self.disabled) { return; } CGFloat delta = 0.0f; CGRect handleFrame = _handleView.frame; CGRect trackFrame = _trackView.frame; CGFloat minimumY = 0.0f; CGFloat maximumY = trackFrame.size.height - handleFrame.size.height; if (self.handleExclusiveInteractionEnabled) { if (touchPoint.y < (handleFrame.origin.y - 20) || touchPoint.y > handleFrame.origin.y + (handleFrame.size.height + 20)) { // This touch is not on the handle; eject. return; } } // Apply the updated Y value plus the previous offset delta = handleFrame.origin.y; handleFrame.origin.y = touchPoint.y - _yOffset; //Clamp the handle, and adjust the y offset to counter going outside the bounds if (handleFrame.origin.y < minimumY) { _yOffset += handleFrame.origin.y; _yOffset = MAX(minimumY, _yOffset); handleFrame.origin.y = minimumY; } else if (handleFrame.origin.y > maximumY) { CGFloat handleOverflow = CGRectGetMaxY(handleFrame) - trackFrame.size.height; _yOffset += handleOverflow; _yOffset = MIN(self.yOffset, handleFrame.size.height); handleFrame.origin.y = MIN(handleFrame.origin.y, maximumY); } _handleView.frame = handleFrame; delta -= handleFrame.origin.y; delta = fabs(delta); // If the delta is not 0.0, but we're at either extreme, // this is first frame we've since reaching that point. // Play a taptic feedback impact if (delta > FLT_EPSILON && (CGRectGetMinY(handleFrame) < FLT_EPSILON || CGRectGetMinY(handleFrame) >= maximumY - FLT_EPSILON)) { [_feedbackGenerator impactOccurred]; } // If the user is doing really granualar swipes, add a subtle amount // of vertical animation so the scroll view isn't jumping on each frame [self setScrollYOffsetForHandleYOffset:floorf(handleFrame.origin.y) animated:NO]; //(delta < 0.51f) } - (void)gestureEnded { self.scrollView.scrollEnabled = YES; self.dragging = NO; [UIView animateWithDuration:0.5f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:0.5f options:0 animations:^{ [self layoutInScrollView]; [self layoutIfNeeded]; } completion:nil]; } - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { if (!self.handleExclusiveInteractionEnabled) { return [super pointInside:point withEvent:event]; } else { CGFloat handleMinY = CGRectGetMinY(self.handleView.frame); CGFloat handleMaxY = CGRectGetMaxY(self.handleView.frame); return (0 <= point.x) && (handleMinY <= point.y) && (point.y <= handleMaxY); } } - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { UIView *result = [super hitTest:point withEvent:event]; if (self.disabled || self.dragging) { return result; } // If the user contacts the screen in a swiping motion, // the scroll view will automatically highjack the touch // event unless we explicitly override it here. self.scrollView.scrollEnabled = (result != self); return result; } #pragma mark - Accessors - - (void)setStyle:(TOScrollBarStyle)style { _style = style; [self configureViewsForStyle:style]; } - (UIColor *)trackTintColor { return self.trackView.tintColor; } - (void)setTrackTintColor:(UIColor *)trackTintColor { self.trackView.tintColor = trackTintColor; } - (UIColor *)handleTintColor { return self.handleView.tintColor; } - (void)setHandleTintColor:(UIColor *)handleTintColor { self.handleView.tintColor = handleTintColor; } - (void)setHidden:(BOOL)hidden { self.userHidden = hidden; [self setHidden:hidden animated:NO]; } - (void)setHidden:(BOOL)hidden animated:(BOOL)animated { // Override. It cannot be shown if it's disabled if (_disabled) { super.hidden = YES; return; } // Simply show or hide it if we're not animating if (animated == NO) { super.hidden = hidden; return; } // Show it if we're going to animate it if (self.hidden && hidden == NO) { super.hidden = NO; [self layoutInScrollView]; [self setNeedsLayout]; } CGRect fromFrame = self.frame; CGRect toFrame = self.frame; CGFloat widestElement = MAX(_trackWidth, _handleWidth); CGFloat hiddenOffset = fromFrame.origin.x + _edgeInset + (widestElement * 2.0f); if (hidden == NO) { fromFrame.origin.x = hiddenOffset; } else { toFrame.origin.x = hiddenOffset; } self.frame = fromFrame; [UIView animateWithDuration:0.3f delay:0.0f usingSpringWithDamping:1.0f initialSpringVelocity:0.1f options:UIViewAnimationOptionBeginFromCurrentState animations:^{ self.frame = toFrame; } completion:^(BOOL finished) { super.hidden = hidden; }]; } #pragma mark - Image Generation - + (UIImage *)verticalCapsuleImageWithWidth:(CGFloat)width { UIImage *image = nil; CGFloat radius = width * 0.5f; CGRect frame = (CGRect){0, 0, width+1, width+1}; UIGraphicsBeginImageContextWithOptions(frame.size, NO, 0.0f); [[UIBezierPath bezierPathWithRoundedRect:frame cornerRadius:radius] fill]; image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); image = [image resizableImageWithCapInsets:UIEdgeInsetsMake(radius, radius, radius, radius) resizingMode:UIImageResizingModeStretch]; image = [image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; return image; } @end