| /* |
| * 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. |
| */ |
| |
| #include "config.h" |
| #include "WKFocusedFormControlView.h" |
| |
| #if PLATFORM(WATCHOS) |
| |
| asm(".linker_option \"-framework\", \"PepperUICore\""); |
| |
| #import <PepperUICore/PUICCrownInputSequencer.h> |
| #import <PepperUICore/PUICCrownInputSequencer_Private.h> |
| #import <WebCore/LocalizedStrings.h> |
| #import <WebCore/WebCoreCALayerExtras.h> |
| #import <wtf/NeverDestroyed.h> |
| #import <wtf/RetainPtr.h> |
| #import <wtf/WeakObjCPtr.h> |
| #import <wtf/text/WTFString.h> |
| |
| static const NSTimeInterval formControlViewAnimationDuration = 0.25; |
| static const CGFloat focusedRectDimmingViewAlpha = 0.8; |
| static const CGFloat focusedRectDimmingViewCornerRadius = 6; |
| static const CGFloat focusedRectExpandedHitTestMargin = 15; |
| static const CGFloat submitButtonHeight = 24; |
| |
| static const NSTimeInterval dimmingViewAnimationDuration = 0.25; |
| // Determines the rate at which digital crown scrolling drives animations in the focus overlay. |
| static const CGFloat digitalCrownPointsPerUnit = 20; |
| static const CGFloat digitalCrownInactiveScrollDistancePerUnit = 4; |
| static const CGFloat digitalCrownActiveScrollDistancePerUnit = 8; |
| static const CGFloat digitalCrownMaximumScrollDistance = 12; |
| |
| static UIBezierPath *pathWithRoundedRectInFrame(CGRect rect, CGFloat borderRadius, CGRect frame) |
| { |
| UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:rect cornerRadius:borderRadius]; |
| [path appendPath:[UIBezierPath bezierPathWithRect:frame]]; |
| return path; |
| } |
| |
| @interface WKFocusedFormControlView () <PUICCrownInputSequencerDelegate> |
| @property (nonatomic, readonly) CAShapeLayer *dimmingMaskLayer; |
| @property (nonatomic) CGRect previousHighlightedFrame; |
| @property (nonatomic) CGRect nextHighlightedFrame; |
| @property (nonatomic) CGRect highlightedFrame; |
| @property (nonatomic, copy) NSString *submitActionName; |
| @property (nonatomic) BOOL hasNextNode; |
| @property (nonatomic) BOOL hasPreviousNode; |
| @end |
| |
| @implementation WKFocusedFormControlView { |
| RetainPtr<UIView> _dimmingView; |
| RetainPtr<UIButton> _submitButton; |
| RetainPtr<UIButton> _dismissButton; |
| RetainPtr<UIView> _submitButtonBackgroundView; |
| RetainPtr<UITapGestureRecognizer> _tapGestureRecognizer; |
| RetainPtr<NSArray<UITextSuggestion *>> _textSuggestions; |
| WeakObjCPtr<id <WKFocusedFormControlViewDelegate>> _delegate; |
| RetainPtr<NSString> _submitActionName; |
| RetainPtr<PUICCrownInputSequencer> _crownInputSequencer; |
| Optional<CGPoint> _initialScrollViewContentOffset; |
| BOOL _hasPendingFocusRequest; |
| } |
| |
| - (instancetype)initWithFrame:(CGRect)frame delegate:(id <WKFocusedFormControlViewDelegate>)delegate |
| { |
| if (!(self = [super initWithFrame:frame])) |
| return nil; |
| |
| _delegate = delegate; |
| _highlightedFrame = CGRectZero; |
| |
| _dimmingView = adoptNS([[UIView alloc] init]); |
| [_dimmingView setOpaque:NO]; |
| [_dimmingView setBackgroundColor:[UIColor colorWithWhite:0 alpha:focusedRectDimmingViewAlpha]]; |
| auto maskLayer = adoptNS([[CAShapeLayer alloc] init]); |
| [maskLayer setBackgroundColor:[UIColor blackColor].CGColor]; |
| [maskLayer setFillRule:kCAFillRuleEvenOdd]; |
| [_dimmingView layer].mask = maskLayer.get(); |
| [maskLayer web_disableAllActions]; |
| |
| // FIXME: If we decide to turn on the focused form control overlay by default, we will need to move the submit and dismiss |
| // buttons to the status bar. See <rdar://problem/39264218>. |
| _submitButton = [UIButton buttonWithType:UIButtonTypeSystem]; |
| [_submitButton addTarget:self action:@selector(didSubmit) forControlEvents:UIControlEventTouchUpInside]; |
| |
| _dismissButton = [UIButton buttonWithType:UIButtonTypeSystem]; |
| [_dismissButton addTarget:self action:@selector(didDismiss) forControlEvents:UIControlEventTouchUpInside]; |
| [_dismissButton setTitle:WebCore::formControlHideButtonTitle() forState:UIControlStateNormal]; |
| [_dismissButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; |
| |
| _tapGestureRecognizer = adoptNS([[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)]); |
| [_tapGestureRecognizer setNumberOfTapsRequired:1]; |
| |
| self.opaque = NO; |
| self.layer.masksToBounds = YES; |
| [self addGestureRecognizer:_tapGestureRecognizer.get()]; |
| |
| _submitButtonBackgroundView = adoptNS([[UIView alloc] init]); |
| [_submitButtonBackgroundView setOpaque:YES]; |
| [_submitButtonBackgroundView setBackgroundColor:[UIColor blackColor]]; |
| |
| [_submitButtonBackgroundView addSubview:_dismissButton.get()]; |
| [_submitButtonBackgroundView addSubview:_submitButton.get()]; |
| [self addSubview:_dimmingView.get()]; |
| [self addSubview:_submitButtonBackgroundView.get()]; |
| |
| _hasPendingFocusRequest = NO; |
| _initialScrollViewContentOffset = WTF::nullopt; |
| |
| return self; |
| } |
| |
| - (BOOL)handleWheelEvent:(UIEvent *)event |
| { |
| [self _wheelChangedWithEvent:event]; |
| return YES; |
| } |
| |
| - (void)show:(BOOL)animated |
| { |
| if (!self.hidden) |
| return; |
| |
| self.hidden = NO; |
| |
| auto fadeInViewBlock = [view = retainPtr(self)] { |
| [view setAlpha:1]; |
| }; |
| |
| if (animated) |
| [UIView animateWithDuration:formControlViewAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState animations:fadeInViewBlock completion:nil]; |
| else |
| fadeInViewBlock(); |
| } |
| |
| - (void)hide:(BOOL)animated |
| { |
| if (self.hidden) |
| return; |
| |
| if (!animated) { |
| self.alpha = 0; |
| self.hidden = YES; |
| return; |
| } |
| |
| [UIView animateWithDuration:formControlViewAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState animations:[view = retainPtr(self)] { |
| [view setAlpha:0]; |
| } completion:[view = retainPtr(self)] (BOOL finished) |
| { |
| if (finished) |
| [view setHidden:YES]; |
| }]; |
| } |
| |
| - (id <WKFocusedFormControlViewDelegate>)delegate |
| { |
| return _delegate.get().get(); |
| } |
| |
| - (void)setDelegate:(id <WKFocusedFormControlViewDelegate>)delegate |
| { |
| _delegate = delegate; |
| } |
| |
| - (CAShapeLayer *)dimmingMaskLayer |
| { |
| return (CAShapeLayer *)[_dimmingView layer].mask; |
| } |
| |
| - (void)handleTap |
| { |
| if (CGRectContainsPoint(CGRectInset(_highlightedFrame, -focusedRectExpandedHitTestMargin, -focusedRectExpandedHitTestMargin), [_tapGestureRecognizer location])) { |
| [_delegate focusedFormControlViewDidBeginEditing:self]; |
| return; |
| } |
| |
| [self didDismiss]; |
| } |
| |
| - (void)_wheelChangedWithEvent:(UIEvent *)event |
| { |
| [super _wheelChangedWithEvent:event]; |
| |
| if ([_crownInputSequencer delegate] == self) |
| [_crownInputSequencer updateWithCrownInputEvent:event]; |
| } |
| |
| - (void)didDismiss |
| { |
| [_delegate focusedFormControlViewDidCancel:self]; |
| } |
| |
| - (void)didSubmit |
| { |
| [_delegate focusedFormControlViewDidSubmit:self]; |
| } |
| |
| - (void)layoutSubviews |
| { |
| [super layoutSubviews]; |
| |
| CGRect viewBounds = self.bounds; |
| [_dimmingView setFrame:UIRectInsetEdges(viewBounds, UIRectEdgeAll, -digitalCrownMaximumScrollDistance)]; |
| [_dismissButton sizeToFit]; |
| [_dismissButton setFrame:CGRect { CGPointMake(0, 0), [_dismissButton size] }]; |
| [_submitButton sizeToFit]; |
| [_submitButton setFrame:CGRect { CGPointMake(CGRectGetWidth(viewBounds) - CGRectGetWidth([_submitButton bounds]), 0), [_submitButton size] }]; |
| [_submitButtonBackgroundView setFrame:CGRectMake(0, 0, CGRectGetWidth(viewBounds), submitButtonHeight)]; |
| } |
| |
| - (void)setHighlightedFrame:(CGRect)highlightedFrame |
| { |
| [self setHighlightedFrame:highlightedFrame animated:NO]; |
| } |
| |
| - (UIBezierPath *)computeDimmingViewCutoutPath |
| { |
| if (CGRectIsEmpty(_highlightedFrame)) |
| return nil; |
| |
| CGRect inflatedHighlightFrame = UIRectInsetEdges(_highlightedFrame, UIRectEdgeAll, -focusedRectDimmingViewCornerRadius); |
| return pathWithRoundedRectInFrame([self convertRect:inflatedHighlightFrame toView:_dimmingView.get()], focusedRectDimmingViewCornerRadius, [_dimmingView bounds]); |
| } |
| |
| - (void)disengageFocusedFormControlNavigation |
| { |
| [self cancelPendingCrownInputSequencerUpdate]; |
| [_crownInputSequencer stopVelocityTrackingAndDecelerationImmediately]; |
| [_crownInputSequencer setOffset:0]; |
| [_crownInputSequencer setDelegate:nil]; |
| } |
| |
| - (void)engageFocusedFormControlNavigation |
| { |
| if (!_crownInputSequencer) { |
| _crownInputSequencer = adoptNS([[PUICCrownInputSequencer alloc] init]); |
| [_crownInputSequencer setRubberBandingEnabled:YES]; |
| [_crownInputSequencer setUseWideIdleCheck:YES]; |
| [_crownInputSequencer setStart:-1]; |
| [_crownInputSequencer setEnd:1]; |
| [_crownInputSequencer setScreenSpaceMultiplier:digitalCrownPointsPerUnit]; |
| } |
| |
| _initialScrollViewContentOffset = self.scrollViewForCrownInputSequencer.contentOffset; |
| [_crownInputSequencer stopVelocityTrackingAndDecelerationImmediately]; |
| [_crownInputSequencer setOffset:0]; |
| [_crownInputSequencer setDelegate:self]; |
| } |
| |
| - (void)reloadData:(BOOL)animated |
| { |
| [self setPreviousHighlightedFrame:[_delegate previousRectForFocusedFormControlView:self]]; |
| [self setNextHighlightedFrame:[_delegate nextRectForFocusedFormControlView:self]]; |
| [self setHighlightedFrame:[_delegate rectForFocusedFormControlView:self] animated:animated]; |
| self.submitActionName = [_delegate actionNameForFocusedFormControlView:self]; |
| self.hasNextNode = [_delegate hasNextNodeForFocusedFormControlView:self]; |
| self.hasPreviousNode = [_delegate hasPreviousNodeForFocusedFormControlView:self]; |
| [self setMaskLayerPosition:CGPointZero animated:animated]; |
| |
| _hasPendingFocusRequest = NO; |
| } |
| |
| - (void)setMaskLayerPosition:(CGPoint)position animated:(BOOL)animated |
| { |
| if (animated) { |
| CABasicAnimation *maskAnimation = [CABasicAnimation animationWithKeyPath:@"position"]; |
| maskAnimation.fromValue = [NSValue valueWithCGPoint:self.dimmingMaskLayer.position]; |
| maskAnimation.toValue = [NSValue valueWithCGPoint:position]; |
| maskAnimation.duration = dimmingViewAnimationDuration; |
| maskAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; |
| [self.dimmingMaskLayer addAnimation:maskAnimation forKey:nil]; |
| } |
| self.dimmingMaskLayer.position = position; |
| } |
| |
| - (void)setHighlightedFrame:(CGRect)highlightedFrame animated:(BOOL)animated |
| { |
| if (CGRectEqualToRect(_highlightedFrame, highlightedFrame)) |
| return; |
| |
| _highlightedFrame = highlightedFrame; |
| |
| UIBezierPath *updatedCutoutPath = [self computeDimmingViewCutoutPath]; |
| if (!animated || !self.dimmingMaskLayer.path || !updatedCutoutPath) { |
| self.dimmingMaskLayer.path = updatedCutoutPath.CGPath; |
| return; |
| } |
| |
| CABasicAnimation *maskAnimation = [CABasicAnimation animationWithKeyPath:@"path"]; |
| maskAnimation.fromValue = (__bridge UIBezierPath *)self.dimmingMaskLayer.path; |
| maskAnimation.toValue = updatedCutoutPath; |
| maskAnimation.duration = dimmingViewAnimationDuration; |
| maskAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; |
| [self.dimmingMaskLayer addAnimation:maskAnimation forKey:nil]; |
| self.dimmingMaskLayer.path = updatedCutoutPath.CGPath; |
| } |
| |
| - (NSString *)submitActionName |
| { |
| return _submitActionName.get(); |
| } |
| |
| static NSDictionary *submitActionNameFontAttributes() |
| { |
| return @{ NSFontAttributeName: [UIFont boldSystemFontOfSize:15] }; |
| } |
| |
| - (void)setSubmitActionName:(NSString *)submitActionName |
| { |
| if (_submitActionName == submitActionName || [_submitActionName isEqualToString:submitActionName]) |
| return; |
| |
| _submitActionName = adoptNS(submitActionName.copy); |
| |
| if (submitActionName.length) { |
| auto boldTitle = adoptNS([[NSAttributedString alloc] initWithString:submitActionName attributes:submitActionNameFontAttributes()]); |
| [_submitButton setAttributedTitle:boldTitle.get() forState:UIControlStateNormal]; |
| } else |
| [_submitButton setAttributedTitle:nil forState:UIControlStateNormal]; |
| |
| [self setNeedsLayout]; |
| } |
| |
| - (UIScrollView *)scrollViewForCrownInputSequencer |
| { |
| return [_delegate scrollViewForFocusedFormControlView:self]; |
| } |
| |
| - (void)updateViewForCurrentCrownInputSequencerState |
| { |
| if (!_initialScrollViewContentOffset) |
| return; |
| |
| CGFloat crownOffset = [_crownInputSequencer offset]; |
| UIScrollView *scrollView = self.scrollViewForCrownInputSequencer; |
| CGPoint targetContentOffset = [self scrollOffsetForCrownInputOffset:crownOffset]; |
| scrollView.contentOffset = targetContentOffset; |
| self.dimmingMaskLayer.position = CGPointMake(_initialScrollViewContentOffset->x - targetContentOffset.x, _initialScrollViewContentOffset->y - targetContentOffset.y); |
| |
| if (_hasPendingFocusRequest) |
| return; |
| |
| if (crownOffset > 1 && self.hasNextNode) { |
| [_delegate focusedFormControlViewDidRequestNextNode:self]; |
| _hasPendingFocusRequest = YES; |
| } |
| |
| if (crownOffset < -1 && self.hasPreviousNode) { |
| [_delegate focusedFormControlViewDidRequestPreviousNode:self]; |
| _hasPendingFocusRequest = YES; |
| } |
| } |
| |
| - (CGPoint)scrollOffsetForCrownInputOffset:(CGFloat)crownOffset |
| { |
| ASSERT(_initialScrollViewContentOffset); |
| if (!crownOffset) |
| return *_initialScrollViewContentOffset; |
| |
| CGPoint highlightedFrameMidpoint { CGRectGetMidX(_highlightedFrame), CGRectGetMidY(_highlightedFrame) }; |
| CGVector unitVectorToTarget = CGVectorMake(0, crownOffset < 0 ? -1 : 1); |
| CGFloat targetHighlightedFrameDeltaX = 0; |
| CGFloat targetHighlightedFrameDeltaY = 0; |
| BOOL isMovingTowardsNextOrPreviousNode = NO; |
| if (crownOffset < 0 && self.hasPreviousNode) { |
| targetHighlightedFrameDeltaX = CGRectGetMidX(_previousHighlightedFrame) - highlightedFrameMidpoint.x; |
| targetHighlightedFrameDeltaY = CGRectGetMidY(_previousHighlightedFrame) - highlightedFrameMidpoint.y; |
| isMovingTowardsNextOrPreviousNode = YES; |
| } else if (crownOffset > 0 && self.hasNextNode) { |
| targetHighlightedFrameDeltaX = CGRectGetMidX(_nextHighlightedFrame) - highlightedFrameMidpoint.x; |
| targetHighlightedFrameDeltaY = CGRectGetMidY(_nextHighlightedFrame) - highlightedFrameMidpoint.y; |
| isMovingTowardsNextOrPreviousNode = YES; |
| } |
| |
| if (targetHighlightedFrameDeltaX || targetHighlightedFrameDeltaY) { |
| CGFloat distanceToTarget = std::sqrt(targetHighlightedFrameDeltaX * targetHighlightedFrameDeltaX + targetHighlightedFrameDeltaY * targetHighlightedFrameDeltaY); |
| unitVectorToTarget = CGVector { targetHighlightedFrameDeltaX / distanceToTarget, targetHighlightedFrameDeltaY / distanceToTarget }; |
| } |
| |
| CGFloat distancePerUnit = isMovingTowardsNextOrPreviousNode ? digitalCrownActiveScrollDistancePerUnit : digitalCrownInactiveScrollDistancePerUnit; |
| CGFloat absoluteClampedCrownOffset = std::min<CGFloat>(digitalCrownMaximumScrollDistance / distancePerUnit, ABS(crownOffset)); |
| return { |
| _initialScrollViewContentOffset->x + distancePerUnit * unitVectorToTarget.dx * absoluteClampedCrownOffset, |
| _initialScrollViewContentOffset->y + distancePerUnit * unitVectorToTarget.dy * absoluteClampedCrownOffset |
| }; |
| } |
| |
| - (void)_crownInputSequencerTimerFired |
| { |
| CGFloat currentCrownInputSequencerOffset = [_crownInputSequencer offset]; |
| if (ABS(currentCrownInputSequencerOffset) < 0.01) { |
| [_crownInputSequencer setOffset:0]; |
| [self updateViewForCurrentCrownInputSequencerState]; |
| return; |
| } |
| |
| [_crownInputSequencer setOffset:0.75 * currentCrownInputSequencerOffset]; |
| [self updateViewForCurrentCrownInputSequencerState]; |
| [self scheduleCrownInputSequencerUpdate]; |
| } |
| |
| - (void)cancelPendingCrownInputSequencerUpdate |
| { |
| [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(_crownInputSequencerTimerFired) object:nil]; |
| } |
| |
| - (void)scheduleCrownInputSequencerUpdate |
| { |
| [self performSelector:@selector(_crownInputSequencerTimerFired) withObject:nil afterDelay:1 / 60.]; |
| } |
| |
| #pragma mark - PUICCrownInputSequencerDelegate |
| |
| - (void)crownInputSequencerOffsetDidChange:(PUICCrownInputSequencer *)crownInputSequencer |
| { |
| [self updateViewForCurrentCrownInputSequencerState]; |
| } |
| |
| - (void)crownInputSequencerDidBecomeIdle:(PUICCrownInputSequencer *)crownInputSequencer willDecelerate:(BOOL)willDecelerate |
| { |
| [_crownInputSequencer stopVelocityTrackingAndDecelerationImmediately]; |
| [self scheduleCrownInputSequencerUpdate]; |
| } |
| |
| - (void)crownInputSequencerIdleDidChange:(PUICCrownInputSequencer *)crownInputSequencer |
| { |
| if (!crownInputSequencer.idle) |
| [self cancelPendingCrownInputSequencerUpdate]; |
| } |
| |
| #pragma mark - UITextInputSuggestionDelegate |
| |
| // FIXME: Move text suggestion state out of the focus form overlay and into the content view or input session. |
| // This class should only be concerned about the focused form overlay. |
| - (NSArray<UITextSuggestion *> *)suggestions |
| { |
| return _textSuggestions.get(); |
| } |
| |
| - (void)setSuggestions:(NSArray<UITextSuggestion *> *)suggestions |
| { |
| if (_textSuggestions == suggestions || [_textSuggestions isEqualToArray:suggestions]) |
| return; |
| |
| _textSuggestions = adoptNS(suggestions.copy); |
| [_delegate focusedFormControllerDidUpdateSuggestions:self]; |
| } |
| |
| - (void)handleWebViewCredentialsSaveForWebsiteURL:(NSURL *)websiteURL user:(NSString *)user password:(NSString *)password passwordIsAutoGenerated:(BOOL)passwordIsAutoGenerated |
| { |
| } |
| |
| - (void)selectionWillChange:(id <UITextInput>)textInput |
| { |
| } |
| |
| - (void)selectionDidChange:(id <UITextInput>)textInput |
| { |
| } |
| |
| - (void)textWillChange:(id <UITextInput>)textInput |
| { |
| } |
| |
| - (void)textDidChange:(id <UITextInput>)textInput |
| { |
| } |
| |
| @end |
| |
| #endif // PLATFORM(WATCHOS) |