blob: 65cb5b5042e333c98c98b3fbaff40b512848f67e [file] [log] [blame]
/*
* Copyright (C) 2018 Apple Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
* THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
* PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
* THE POSSIBILITY OF SUCH DAMAGE.
*/
#import "config.h"
#import "WKKeyboardScrollingAnimator.h"
#if PLATFORM(IOS_FAMILY)
#import "AccessibilitySupportSPI.h"
#import "UIKitSPI.h"
#import <QuartzCore/CADisplayLink.h>
#import <WebCore/FloatPoint.h>
#import <WebCore/KeyEventCodesIOS.h>
#import <WebCore/RectEdges.h>
#import <WebCore/WebEvent.h>
#import <WebKit/UIKitSPI.h>
#import <algorithm>
#import <wtf/RetainPtr.h>
#import <wtf/WeakObjCPtr.h>
namespace WebKit {
struct KeyboardScroll {
WebCore::FloatSize offset; // Points per increment.
WebCore::FloatSize maximumVelocity; // Points per second.
WebCore::FloatSize force;
WebKit::ScrollingIncrement increment;
WebKit::ScrollingDirection direction;
};
struct KeyboardScrollParameters {
CGFloat springMass { 1 };
CGFloat springStiffness { 109 };
CGFloat springDamping { 20 };
CGFloat maximumVelocityMultiplier { 25 };
CGFloat timeToMaximumVelocity { 1 };
CGFloat rubberBandForce { 5000 };
};
}
@protocol WKKeyboardScrollableInternal <NSObject>
@required
- (BOOL)isKeyboardScrollable;
- (CGFloat)distanceForIncrement:(WebKit::ScrollingIncrement)increment inDirection:(WebKit::ScrollingDirection)direction;
- (void)scrollToContentOffset:(WebCore::FloatPoint)offset animated:(BOOL)animated;
- (void)scrollWithScrollToExtentAnimationTo:(CGPoint)offset;
- (CGPoint)contentOffset;
- (CGSize)interactiveScrollVelocity;
- (CGPoint)boundedContentOffset:(CGPoint)offset;
- (WebCore::RectEdges<bool>)scrollableDirectionsFromOffset:(CGPoint)offset;
- (WebCore::RectEdges<bool>)rubberbandableDirections;
- (void)didFinishScrolling;
@end
@interface WKKeyboardScrollingAnimator : NSObject
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithScrollable:(id <WKKeyboardScrollableInternal>)scrollable;
- (void)invalidate;
- (void)willStartInteractiveScroll;
- (BOOL)beginWithEvent:(::WebEvent *)event;
- (void)handleKeyEvent:(::WebEvent *)event;
@end
@implementation WKKeyboardScrollingAnimator {
id <WKKeyboardScrollableInternal> _scrollable;
RetainPtr<CADisplayLink> _displayLink;
Optional<WebKit::KeyboardScroll> _currentScroll;
BOOL _scrollTriggeringKeyIsPressed;
WebCore::FloatSize _velocity; // Points per second.
WebCore::FloatPoint _idealPosition;
WebCore::FloatPoint _currentPosition;
WebCore::FloatPoint _idealPositionForMinimumTravel;
}
- (instancetype)init
{
return nil;
}
- (instancetype)initWithScrollable:(id <WKKeyboardScrollableInternal>)scrollable
{
self = [super init];
if (!self)
return nil;
_scrollable = scrollable;
return self;
}
- (const WebKit::KeyboardScrollParameters &)parameters
{
static const WebKit::KeyboardScrollParameters parameters;
return parameters;
}
- (void)invalidate
{
[self stopAnimatedScroll];
[self stopDisplayLink];
_scrollable = nil;
}
static WebCore::FloatSize unitVector(WebKit::ScrollingDirection direction)
{
switch (direction) {
case WebKit::ScrollingDirection::Up:
return { 0, -1 };
case WebKit::ScrollingDirection::Down:
return { 0, 1 };
case WebKit::ScrollingDirection::Left:
return { -1, 0 };
case WebKit::ScrollingDirection::Right:
return { 1, 0 };
}
}
static WebCore::FloatSize perpendicularAbsoluteUnitVector(WebKit::ScrollingDirection direction)
{
switch (direction) {
case WebKit::ScrollingDirection::Up:
case WebKit::ScrollingDirection::Down:
return { 1, 0 };
case WebKit::ScrollingDirection::Left:
case WebKit::ScrollingDirection::Right:
return { 0, 1 };
}
}
static WebCore::PhysicalBoxSide boxSide(WebKit::ScrollingDirection direction)
{
switch (direction) {
case WebKit::ScrollingDirection::Up:
return WebCore::PhysicalBoxSide::Top;
case WebKit::ScrollingDirection::Down:
return WebCore::PhysicalBoxSide::Bottom;
case WebKit::ScrollingDirection::Left:
return WebCore::PhysicalBoxSide::Left;
case WebKit::ScrollingDirection::Right:
return WebCore::PhysicalBoxSide::Right;
}
}
- (Optional<WebKit::KeyboardScroll>)keyboardScrollForEvent:(::WebEvent *)event
{
static const unsigned kWebSpaceKey = 0x20;
if (![_scrollable isKeyboardScrollable])
return WTF::nullopt;
NSString *charactersIgnoringModifiers = event.charactersIgnoringModifiers;
if (!charactersIgnoringModifiers.length)
return WTF::nullopt;
enum class Key : uint8_t { Other, LeftArrow, RightArrow, UpArrow, DownArrow, PageUp, PageDown, Space };
auto key = ^{
auto firstCharacter = [charactersIgnoringModifiers characterAtIndex:0];
switch (firstCharacter) {
case NSLeftArrowFunctionKey:
return Key::LeftArrow;
case NSRightArrowFunctionKey:
return Key::RightArrow;
case NSUpArrowFunctionKey:
return Key::UpArrow;
case NSDownArrowFunctionKey:
return Key::DownArrow;
case NSPageDownFunctionKey:
return Key::PageDown;
case NSPageUpFunctionKey:
return Key::PageUp;
case kWebSpaceKey:
return Key::Space;
default:
return Key::Other;
};
}();
if (key == Key::Other)
return WTF::nullopt;
BOOL shiftPressed = event.modifierFlags & WebEventFlagMaskShiftKey;
BOOL altPressed = event.modifierFlags & WebEventFlagMaskOptionKey;
BOOL cmdPressed = event.modifierFlags & WebEventFlagMaskCommandKey;
// No shortcuts include more than one modifier; we should not eat key events
// that contain more than one modifier because they might be used for other shortcuts.
if (shiftPressed + altPressed + cmdPressed > 1)
return WTF::nullopt;
auto allowedModifiers = ^ WebEventFlags {
switch (key) {
case Key::LeftArrow:
case Key::RightArrow:
return WebEventFlagMaskOptionKey;
case Key::UpArrow:
case Key::DownArrow:
return WebEventFlagMaskOptionKey | WebEventFlagMaskCommandKey;
case Key::PageUp:
case Key::PageDown:
return 0;
case Key::Space:
return WebEventFlagMaskShiftKey;
case Key::Other:
ASSERT_NOT_REACHED();
return 0;
};
}();
auto relevantModifierFlags = WebEventFlagMaskOptionKey | WebEventFlagMaskCommandKey | WebEventFlagMaskShiftKey;
if (event.modifierFlags & relevantModifierFlags & ~allowedModifiers)
return WTF::nullopt;
auto increment = ^{
switch (key) {
case Key::LeftArrow:
case Key::RightArrow:
if (altPressed)
return WebKit::ScrollingIncrement::Page;
return WebKit::ScrollingIncrement::Line;
case Key::UpArrow:
case Key::DownArrow:
if (altPressed)
return WebKit::ScrollingIncrement::Page;
if (cmdPressed)
return WebKit::ScrollingIncrement::Document;
return WebKit::ScrollingIncrement::Line;
case Key::PageUp:
case Key::PageDown:
case Key::Space:
return WebKit::ScrollingIncrement::Page;
case Key::Other:
ASSERT_NOT_REACHED();
return WebKit::ScrollingIncrement::Line;
};
}();
auto direction = ^() {
switch (key) {
case Key::LeftArrow:
return WebKit::ScrollingDirection::Left;
case Key::RightArrow:
return WebKit::ScrollingDirection::Right;
case Key::UpArrow:
case Key::PageUp:
return WebKit::ScrollingDirection::Up;
case Key::DownArrow:
case Key::PageDown:
return WebKit::ScrollingDirection::Down;
case Key::Space:
return shiftPressed ? WebKit::ScrollingDirection::Up : WebKit::ScrollingDirection::Down;
case Key::Other:
ASSERT_NOT_REACHED();
return WebKit::ScrollingDirection::Down;
};
}();
CGFloat scrollDistance = [_scrollable distanceForIncrement:increment inDirection:direction];
WebKit::KeyboardScroll scroll;
scroll.offset = unitVector(direction).scaled(scrollDistance);
scroll.increment = increment;
scroll.direction = direction;
scroll.maximumVelocity = scroll.offset.scaled(self.parameters.maximumVelocityMultiplier);
// Apply a constant force to achieve Vmax in timeToMaximumVelocity seconds.
// F_constant = m * Vmax / t
scroll.force = scroll.maximumVelocity.scaled(self.parameters.springMass / self.parameters.timeToMaximumVelocity);
return scroll;
}
- (BOOL)beginWithEvent:(::WebEvent *)event
{
if (event.type != WebEventKeyDown)
return NO;
auto scroll = [self keyboardScrollForEvent:event];
if (!scroll)
return NO;
if (_scrollTriggeringKeyIsPressed)
return NO;
if (![_scrollable rubberbandableDirections].at(boxSide(scroll->direction)))
return NO;
_scrollTriggeringKeyIsPressed = YES;
_currentScroll = scroll;
if (scroll->increment == WebKit::ScrollingIncrement::Document) {
_velocity = { };
[self stopAnimatedScroll];
[self stopDisplayLink];
[_scrollable scrollWithScrollToExtentAnimationTo:[_scrollable boundedContentOffset:_currentPosition + scroll->offset]];
return YES;
}
[self startDisplayLinkIfNeeded];
_currentPosition = WebCore::FloatPoint([_scrollable contentOffset]);
_velocity += WebCore::FloatSize([_scrollable interactiveScrollVelocity]);
_idealPositionForMinimumTravel = _currentPosition + _currentScroll->offset;
return YES;
}
- (void)handleKeyEvent:(::WebEvent *)event
{
if (!_scrollTriggeringKeyIsPressed)
return;
auto scroll = [self keyboardScrollForEvent:event];
// UIKit does not emit a keyup event when the Command key is down. See <rdar://problem/49523065>.
// For recognized key commands that include the Command key (e.g. Command + Arrow Up) we reset our
// state on keydown.
if (!scroll || event.type == WebEventKeyUp || (event.modifierFlags & WebEventFlagMaskCommandKey)) {
[self stopAnimatedScroll];
_scrollTriggeringKeyIsPressed = NO;
}
}
static WebCore::FloatPoint farthestPointInDirection(WebCore::FloatPoint a, WebCore::FloatPoint b, WebKit::ScrollingDirection direction)
{
switch (direction) {
case WebKit::ScrollingDirection::Up:
return WebCore::FloatPoint(a.x(), std::min(a.y(), b.y()));
case WebKit::ScrollingDirection::Down:
return WebCore::FloatPoint(a.x(), std::max(a.y(), b.y()));
case WebKit::ScrollingDirection::Left:
return WebCore::FloatPoint(std::min(a.x(), b.x()), a.y());
case WebKit::ScrollingDirection::Right:
return WebCore::FloatPoint(std::max(a.x(), b.x()), a.y());
}
ASSERT_NOT_REACHED();
return { };
}
- (void)stopAnimatedScroll
{
if (!_currentScroll)
return;
// Determine the settling position of the spring, conserving the system's current energy.
// Kinetic = elastic potential
// 1/2 * m * v^2 = 1/2 * k * x^2
// x = sqrt(v^2 * m / k)
auto displacementMagnitudeSquared = (_velocity * _velocity).scaled(self.parameters.springMass / self.parameters.springStiffness);
WebCore::FloatSize displacement = {
std::copysign(sqrt(displacementMagnitudeSquared.width()), _velocity.width()),
std::copysign(sqrt(displacementMagnitudeSquared.height()), _velocity.height())
};
// If the spring would settle before the minimum travel distance
// for an instantaneous tap, move the settling position of the spring
// out to that point.
_idealPosition = [_scrollable boundedContentOffset:farthestPointInDirection(_currentPosition + displacement, _idealPositionForMinimumTravel, _currentScroll->direction)];
_currentScroll = WTF::nullopt;
}
- (BOOL)scrollTriggeringKeyIsPressed
{
return _scrollTriggeringKeyIsPressed;
}
- (void)willStartInteractiveScroll
{
// If the user touches the screen to start an interactive scroll, stop everything.
_velocity = { };
[self stopAnimatedScroll];
[self stopDisplayLink];
}
- (void)startDisplayLinkIfNeeded
{
if (_displayLink)
return;
_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkFired:)];
[_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}
- (void)stopDisplayLink
{
[_displayLink invalidate];
_displayLink = nil;
}
- (void)displayLinkFired:(CADisplayLink *)sender
{
WebCore::FloatSize force;
WebCore::FloatSize axesToApplySpring = { 1, 1 };
if (_currentScroll) {
auto scrollableDirections = [_scrollable scrollableDirectionsFromOffset:_currentPosition];
auto direction = _currentScroll->direction;
if (scrollableDirections.at(boxSide(direction))) {
// Apply the scrolling force. Only apply the spring in the perpendicular axis,
// otherwise it drags against the direction of motion.
axesToApplySpring = perpendicularAbsoluteUnitVector(direction);
force = _currentScroll->force;
} else {
// The scroll view cannot scroll in this direction, and is rubber-banding.
// Apply a constant and significant force; otherwise, the force for a
// single-line increment is not strong enough to rubber-band perceptibly.
force = unitVector(direction).scaled(self.parameters.rubberBandForce);
}
// If we've reached or exceeded the maximum velocity, stop applying any force.
// However, we won't let the spring snap, we'll just keep going at the same
// velocity until the user raises their finger or we hit an edge.
if (fabs(_velocity.width()) >= fabs(_currentScroll->maximumVelocity.width()))
force.setWidth(0);
if (fabs(_velocity.height()) >= fabs(_currentScroll->maximumVelocity.height()))
force.setHeight(0);
}
WebCore::FloatPoint idealPosition = [_scrollable boundedContentOffset:_currentScroll ? _currentPosition : _idealPosition];
WebCore::FloatSize displacement = _currentPosition - idealPosition;
// Compute the spring's force, and apply it in allowed directions.
// F_spring = -k * x - c * v
auto springForce = - displacement.scaled(self.parameters.springStiffness) - _velocity.scaled(self.parameters.springDamping);
force += springForce * axesToApplySpring;
// Integrate acceleration -> velocity -> position for this time step.
CFTimeInterval frameDuration = sender.targetTimestamp - sender.timestamp;
WebCore::FloatSize acceleration = force.scaled(1. / self.parameters.springMass);
_velocity += acceleration.scaled(frameDuration);
_currentPosition += _velocity.scaled(frameDuration);
[_scrollable scrollToContentOffset:_currentPosition animated:NO];
// If we've effectively stopped scrolling, and no key is pressed,
// shut down the display link.
if (!_scrollTriggeringKeyIsPressed && _velocity.diagonalLengthSquared() < 1) {
[_scrollable didFinishScrolling];
[self stopDisplayLink];
_velocity = { };
}
}
@end
@interface WKKeyboardScrollViewAnimator () <WKKeyboardScrollableInternal>
@end
@implementation WKKeyboardScrollViewAnimator {
WeakObjCPtr<UIScrollView> _scrollView;
RetainPtr<WKKeyboardScrollingAnimator> _animator;
BOOL _delegateRespondsToIsKeyboardScrollable;
BOOL _delegateRespondsToDistanceForIncrement;
BOOL _delegateRespondsToWillScroll;
BOOL _delegateRespondsToDidFinishScrolling;
}
- (instancetype)init
{
return nil;
}
- (instancetype)initWithScrollView:(UIScrollView *)scrollView
{
self = [super init];
if (!self)
return nil;
_scrollView = scrollView;
_animator = adoptNS([[WKKeyboardScrollingAnimator alloc] initWithScrollable:self]);
return self;
}
- (void)dealloc
{
[_animator invalidate];
[super dealloc];
}
- (void)invalidate
{
_scrollView = nil;
[_animator invalidate];
_animator = nil;
}
- (void)setDelegate:(id <WKKeyboardScrollViewAnimatorDelegate>)delegate
{
_delegate = delegate;
_delegateRespondsToIsKeyboardScrollable = [_delegate respondsToSelector:@selector(isScrollableForKeyboardScrollViewAnimator:)];
_delegateRespondsToDistanceForIncrement = [_delegate respondsToSelector:@selector(keyboardScrollViewAnimator:distanceForIncrement:inDirection:)];
_delegateRespondsToWillScroll = [_delegate respondsToSelector:@selector(keyboardScrollViewAnimatorWillScroll:)];
_delegateRespondsToDidFinishScrolling = [_delegate respondsToSelector:@selector(keyboardScrollViewAnimatorDidFinishScrolling:)];
}
- (void)willStartInteractiveScroll
{
[_animator willStartInteractiveScroll];
}
- (BOOL)beginWithEvent:(::WebEvent *)event
{
return [_animator beginWithEvent:event];
}
- (void)handleKeyEvent:(::WebEvent *)event
{
return [_animator handleKeyEvent:event];
}
- (BOOL)scrollTriggeringKeyIsPressed
{
return [_animator scrollTriggeringKeyIsPressed];
}
- (BOOL)isKeyboardScrollable
{
if (!_delegateRespondsToIsKeyboardScrollable)
return YES;
return [_delegate isScrollableForKeyboardScrollViewAnimator:self];
}
- (CGFloat)distanceForIncrement:(WebKit::ScrollingIncrement)increment inDirection:(WebKit::ScrollingDirection)direction
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return 0;
const CGFloat defaultPageScrollFraction = 0.8;
const CGFloat defaultLineScrollHeight = 40;
BOOL directionIsHorizontal = direction == WebKit::ScrollingDirection::Left || direction == WebKit::ScrollingDirection::Right;
if (!_delegateRespondsToDistanceForIncrement) {
switch (increment) {
case WebKit::ScrollingIncrement::Document:
return directionIsHorizontal ? scrollView.contentSize.width : scrollView.contentSize.height;
case WebKit::ScrollingIncrement::Page:
return (directionIsHorizontal ? scrollView.frame.size.width : scrollView.frame.size.height) * defaultPageScrollFraction;
case WebKit::ScrollingIncrement::Line:
return defaultLineScrollHeight * scrollView.zoomScale;
}
ASSERT_NOT_REACHED();
return 0;
}
return [_delegate keyboardScrollViewAnimator:self distanceForIncrement:increment inDirection:direction];
}
#if HAVE(UI_SCROLL_VIEW_INDICATOR_FLASHING_SPI)
static UIAxis axesForDelta(WebCore::FloatSize delta)
{
UIAxis axes = UIAxisNeither;
if (delta.width())
axes = static_cast<UIAxis>(axes | UIAxisHorizontal);
if (delta.height())
axes = static_cast<UIAxis>(axes | UIAxisVertical);
return axes;
}
#endif
- (void)scrollToContentOffset:(WebCore::FloatPoint)contentOffset animated:(BOOL)animated
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return;
if (_delegateRespondsToWillScroll)
[_delegate keyboardScrollViewAnimatorWillScroll:self];
[scrollView setContentOffset:contentOffset animated:animated];
#if HAVE(UI_SCROLL_VIEW_INDICATOR_FLASHING_SPI)
[scrollView _flashScrollIndicatorsForAxes:axesForDelta(WebCore::FloatPoint(scrollView.contentOffset) - contentOffset) persistingPreviousFlashes:YES];
#else
[scrollView _flashScrollIndicatorsPersistingPreviousFlashes:YES];
#endif
}
- (void)scrollWithScrollToExtentAnimationTo:(CGPoint)offset
{
auto scrollView = _scrollView.getAutoreleased();
[scrollView _setContentOffsetWithDecelerationAnimation:offset];
[scrollView flashScrollIndicators];
}
- (CGPoint)contentOffset
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return CGPointZero;
return [scrollView contentOffset];
}
- (CGPoint)boundedContentOffset:(CGPoint)offset
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return CGPointZero;
return [scrollView _adjustedContentOffsetForContentOffset:offset];
}
- (CGSize)interactiveScrollVelocity
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return CGSizeZero;
const NSTimeInterval millisecondsPerSecond = 1000;
return CGSizeMake(scrollView._horizontalVelocity * millisecondsPerSecond, scrollView._verticalVelocity * millisecondsPerSecond);
}
- (WebCore::RectEdges<bool>)scrollableDirectionsFromOffset:(CGPoint)offset
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return { };
UIEdgeInsets contentInsets = scrollView.adjustedContentInset;
CGSize contentSize = scrollView.contentSize;
CGSize scrollViewSize = scrollView.bounds.size;
CGPoint minimumContentOffset = CGPointMake(-contentInsets.left, -contentInsets.top);
CGPoint maximumContentOffset = CGPointMake(std::max(minimumContentOffset.x, contentSize.width + contentInsets.right - scrollViewSize.width), std::max(minimumContentOffset.y, contentSize.height + contentInsets.bottom - scrollViewSize.height));
WebCore::RectEdges<bool> edges;
edges.setTop(offset.y > minimumContentOffset.y);
edges.setBottom(offset.y < maximumContentOffset.y);
edges.setLeft(offset.x > minimumContentOffset.x);
edges.setRight(offset.x < maximumContentOffset.x);
return edges;
}
- (WebCore::RectEdges<bool>)rubberbandableDirections
{
auto scrollView = _scrollView.getAutoreleased();
if (!scrollView)
return { };
WebCore::RectEdges<bool> edges;
edges.setTop(scrollView._canScrollWithoutBouncingY);
edges.setBottom(edges.top());
edges.setLeft(scrollView._canScrollWithoutBouncingX);
edges.setRight(edges.left());
return edges;
}
- (void)didFinishScrolling
{
if (_delegateRespondsToDidFinishScrolling)
[_delegate keyboardScrollViewAnimatorDidFinishScrolling:self];
}
@end
#endif // PLATFORM(IOS_FAMILY)