blob: 2f0342dde4bb98cbf0ad3718fc5b8396ca8e57c5 [file] [log] [blame] [edit]
// Copyright 2019-present the Material Components for iOS authors. 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 "MDCStatefulRippleView.h"
#import "private/MDCRippleLayer.h"
static const CGFloat kDefaultRippleAlpha = (CGFloat)0.12;
static const CGFloat kDefaultRippleSelectedAlpha = (CGFloat)0.08;
static const CGFloat kDefaultRippleDraggedAlpha = (CGFloat)0.08;
static UIColor *RippleSelectedColor(void) {
return [UIColor colorWithRed:(CGFloat)0.384 green:0 blue:(CGFloat)0.933 alpha:1];
}
@interface MDCStatefulRippleView ()
@property(nonatomic, strong) MDCRippleLayer *activeRippleLayer;
@end
@implementation MDCStatefulRippleView {
NSMutableDictionary<NSNumber *, UIColor *> *_rippleColors;
BOOL _tapWentOutsideOfBounds;
BOOL _tapWentInsideOfBounds;
BOOL _didReceiveTouch;
CGPoint _lastTouch;
}
@dynamic activeRippleLayer;
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonMDCStatefulRippleViewInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder {
self = [super initWithCoder:coder];
if (self) {
[self commonMDCStatefulRippleViewInit];
}
return self;
}
- (void)commonMDCStatefulRippleViewInit {
if (_rippleColors == nil) {
_rippleColors = [NSMutableDictionary dictionary];
UIColor *selectionColor = RippleSelectedColor();
_rippleColors[@(MDCRippleStateNormal)] = [UIColor colorWithWhite:0 alpha:kDefaultRippleAlpha];
_rippleColors[@(MDCRippleStateHighlighted)] = [UIColor colorWithWhite:0
alpha:kDefaultRippleAlpha];
_rippleColors[@(MDCRippleStateSelected)] =
[selectionColor colorWithAlphaComponent:kDefaultRippleSelectedAlpha];
_rippleColors[@(MDCRippleStateSelected | MDCRippleStateHighlighted)] =
[selectionColor colorWithAlphaComponent:kDefaultRippleAlpha];
_rippleColors[@(MDCRippleStateDragged)] = [UIColor colorWithWhite:0
alpha:kDefaultRippleDraggedAlpha];
_rippleColors[@(MDCRippleStateDragged | MDCRippleStateHighlighted)] =
[UIColor colorWithWhite:0 alpha:kDefaultRippleDraggedAlpha];
_rippleColors[@(MDCRippleStateSelected | MDCRippleStateDragged)] =
[selectionColor colorWithAlphaComponent:kDefaultRippleDraggedAlpha];
}
}
- (UIColor *)rippleColorForState:(MDCRippleState)state {
UIColor *rippleColor = _rippleColors[@(state)];
if (rippleColor == nil && (state & MDCRippleStateDragged) != 0) {
rippleColor = _rippleColors[@(MDCRippleStateDragged)];
} else if (rippleColor == nil && (state & MDCRippleStateSelected) != 0) {
rippleColor = _rippleColors[@(MDCRippleStateSelected)];
}
if (rippleColor == nil) {
rippleColor = _rippleColors[@(MDCRippleStateNormal)];
}
return rippleColor;
}
- (void)updateRippleColor {
UIColor *rippleColor = [self rippleColorForState:self.state];
[self setRippleColor:rippleColor];
}
- (void)updateActiveRippleColor {
UIColor *rippleColor = [self rippleColorForState:self.state];
[self setActiveRippleColor:rippleColor];
}
- (void)setRippleColor:(UIColor *)rippleColor forState:(MDCRippleState)state {
_rippleColors[@(state)] = rippleColor;
[self updateRippleColor];
}
- (MDCRippleState)state {
NSInteger state = 0;
if (self.selected) {
state |= MDCRippleStateSelected;
}
if (self.rippleHighlighted) {
state |= MDCRippleStateHighlighted;
}
if (self.dragged) {
state |= MDCRippleStateDragged;
}
return state;
}
- (void)setAllowsSelection:(BOOL)allowsSelection {
if (!allowsSelection && self.selected) {
self.selected = NO;
}
_allowsSelection = allowsSelection;
}
- (void)setSelected:(BOOL)selected {
if (!self.allowsSelection) {
// If we disallow selection we don't want to apply any visual or state changes for selection.
return;
}
if (_tapWentOutsideOfBounds) {
// If the tap goes outside of bounds when a selection state change is triggered, we want to
// return early and not issue the selection state change as guidelines dictate that if a tap is
// let go outside the bounds, it should not trigger an action like issuing
// a selection/deselection.
return;
}
if (selected == _selected && self.activeRippleLayer) {
// If selected is already set to YES, and there is already an active ripple layer apparent,
// we want to return early so we don't add multiple selected overlays, as there can be only one.
return;
}
_selected = selected;
// Go into the selected state visually.
if (selected) {
if (!self.activeRippleLayer) {
// If we go into the selected state but a ripple layer doesn't exist yet, it means we went
// into this state without initially creating the ripple overlay by going through the
// highlighted state. This usually occurs when cells are reused and the selected state is
// manually set to show the cell's existing state.
[self updateRippleColor];
[self beginRippleTouchDownAtPoint:_lastTouch animated:NO completion:nil];
} else {
[self updateActiveRippleColor];
}
} else {
// If we are no longer selecting, we cancel all the ripples.
[self updateRippleColor];
[self cancelAllRipplesAnimated:YES completion:nil];
}
}
- (void)setRippleHighlighted:(BOOL)rippleHighlighted {
if (rippleHighlighted == _rippleHighlighted) {
return;
}
_rippleHighlighted = rippleHighlighted;
// Go into the highlighted state visually.
if (rippleHighlighted && !_tapWentInsideOfBounds) {
// If ripple becomes highlighted we initiate a ripple with animation.
[self updateRippleColor];
[self beginRippleTouchDownAtPoint:_lastTouch animated:_didReceiveTouch completion:nil];
} else if (!rippleHighlighted) {
// In cases where the ripple stops being highlighted, we can only dissolve the ripple if we are
// not going into selection (as in that case it will stay and become a selected color and be an
// overlay), or when it is already selected and therefore it means it will stay selected and
// the ripple should act similarly to going in and out of highlighted and offer a standard
// ripple touch feedback on top of the selected overlay.
BOOL notAllowingSelectionOrAlreadySelected = !self.allowsSelection || self.selected;
// We should dissolve the ripple in these cases:
// 1. where this is a normal tap going in and out of highlighted indicating a ripple effect,
// same goes to when there is already a selected overlay on top of it.
// 2. when also we aren't currently in dragged because in dragged we keep the overlay there and
// when dragged is set to NO it releases all the overlays.
// 3. lastly also when the tap isn't currently out of the bounds of the surface, as in that case
// the behavior of the ripple returns to its original state as releasing outside the bounds
// acts as "no action was done".
BOOL shouldDissolveRipple =
notAllowingSelectionOrAlreadySelected && !self.dragged && !_tapWentOutsideOfBounds;
if (shouldDissolveRipple) {
// We dissolve the ripple when highlighted is NO, unless we are going into
// selection or dragging.
[self updateRippleColor];
[self beginRippleTouchUpAnimated:YES completion:nil];
}
}
}
- (void)setDragged:(BOOL)dragged {
if (dragged == _dragged) {
return;
}
_dragged = dragged;
// Go into the dragged state visually.
if (dragged) {
if (!self.activeRippleLayer) {
// If we go into the dragged state manually, without coming from the highlighted state,
// We present the ripple overlay instantly without animation.
[self updateRippleColor];
[self beginRippleTouchDownAtPoint:_lastTouch animated:NO completion:nil];
} else {
[self updateActiveRippleColor];
}
} else {
// If we are no longer dragging, we cancel all the ripples.
[self updateRippleColor];
[self cancelAllRipplesAnimated:YES completion:nil];
}
}
#pragma mark - Superview Touch Handling
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
UITouch *touch = touches.anyObject;
CGPoint point = [touch locationInView:self];
// Once we find that the tap is inside the hit area insets of the encapsulating view (superview
// of the ripple view), we would want to capture the touch from where to initiate the ripple,
// and also initialize the values to indicate there was a touch, and the held tap's location
// to fade the ripple in and out if the help tap goes inside or outside the hit area.
_lastTouch = point;
if (!_didReceiveTouch) {
_didReceiveTouch = YES;
_tapWentInsideOfBounds = NO;
_tapWentOutsideOfBounds = NO;
}
}
- (BOOL)pointInsideSuperview:(CGPoint)point withEvent:(UIEvent *)event {
return [self.superview pointInside:point withEvent:event];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// When the touch is held and moved outside and inside the bounds of the surface,
// the ripple should gracefully fade out and in accordingly.
UITouch *touch = touches.anyObject;
CGPoint point = [touch locationInView:self];
BOOL pointContainedInSuperview = [self pointInsideSuperview:point withEvent:event];
if (pointContainedInSuperview && _tapWentOutsideOfBounds) {
_tapWentInsideOfBounds = YES;
_tapWentOutsideOfBounds = NO;
[self fadeInRippleAnimated:YES completion:nil];
} else if (!pointContainedInSuperview && !_tapWentOutsideOfBounds) {
_tapWentOutsideOfBounds = YES;
[self fadeOutRippleAnimated:YES completion:nil];
}
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
_didReceiveTouch = NO;
if (_tapWentOutsideOfBounds) {
[self beginRippleTouchUpAnimated:NO completion:nil];
}
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
_didReceiveTouch = NO;
[self beginRippleTouchUpAnimated:YES completion:nil];
}
@end