chromium/ios/chrome/browser/orchestrator/ui_bundled/omnibox_focus_orchestrator.mm

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#import "ios/chrome/browser/orchestrator/ui_bundled/omnibox_focus_orchestrator.h"

#import "base/check.h"
#import "ios/chrome/browser/shared/public/features/features.h"
#import "ios/chrome/browser/orchestrator/ui_bundled/edit_view_animatee.h"
#import "ios/chrome/browser/orchestrator/ui_bundled/location_bar_animatee.h"
#import "ios/chrome/browser/orchestrator/ui_bundled/toolbar_animatee.h"
#import "ios/chrome/common/material_timing.h"

@interface OmniboxFocusOrchestrator ()

@property(nonatomic, assign) BOOL isAnimating;
@property(nonatomic, assign) BOOL stateChangedDuringAnimation;
@property(nonatomic, assign) BOOL finalOmniboxFocusedState;
@property(nonatomic, assign) BOOL finalToolbarExpandedState;
@property(nonatomic, assign) int inProgressAnimationCount;

// Sometimes, the toolbar animations finish before the omnibox animations are
// even queued, causing the final completions to be run too early.
@property(nonatomic, assign) BOOL areOmniboxChangesQueued;

@end

@implementation OmniboxFocusOrchestrator {
  ProceduralBlock _completion;
  OmniboxFocusTrigger _trigger;
}

- (void)transitionToStateOmniboxFocused:(BOOL)omniboxFocused
                        toolbarExpanded:(BOOL)toolbarExpanded
                                trigger:(OmniboxFocusTrigger)trigger
                               animated:(BOOL)animated
                             completion:(ProceduralBlock)completion {
  _completion = completion;
  _trigger = trigger;
  // If a new transition is requested while one is ongoing, we don't want
  // to start the new one immediately. However, we do want the omnibox to end
  // up in whatever state was requested last. Therefore, we cache the last
  // requested state and set the omnibox to that state (without animation) at
  // the end of the animations. This may look jerky, but will cause the
  // final state to be a valid one.
  if (self.isAnimating) {
    self.stateChangedDuringAnimation = YES;
    self.finalOmniboxFocusedState = omniboxFocused;
    self.finalToolbarExpandedState = toolbarExpanded;
    return;
  }

  self.isAnimating = animated;
  self.areOmniboxChangesQueued = NO;
  self.inProgressAnimationCount = 0;

  if (toolbarExpanded) {
    [self updateUIToExpandedState:animated];
  } else {
    [self updateUIToContractedState:animated];
  }

  // Make the rest of the animation happen on the next runloop when this
  // animation have calculated the final frame for the location bar.
  // This is necessary because expanding/contracting the toolbar is actually
  // changing the view layout. Therefore, the expand/contract animations are
  // actually moving views (through modifying the constraints). At the same time
  // the focus/defocus animation don't actually modify the view position, the
  // views remain in place, so it's better to animate them with transforms.
  // The cleanest way to compute and perform the transform animation together
  // with a constraint animation seems to be to let the constraint animation
  // start and compute the final frames, then perform the transform animation.
  dispatch_async(dispatch_get_main_queue(), ^{
    self.areOmniboxChangesQueued = YES;
    if (omniboxFocused) {
      [self focusOmniboxAnimated:animated];
    } else {
      [self defocusOmniboxAnimated:animated];
    }

    // Make sure that some omnibox animations were queued. Otherwise, the final
    // call to `animationFinished` after the toolbar animations finished was
    // interrupted and cleanup still needs to occur.
    if (self.inProgressAnimationCount == 0 && self.isAnimating) {
      [self cleanupAfterAnimations];
    }
  });
}

#pragma mark - Private

- (void)focusOmniboxAnimated:(BOOL)animated {
  // Cleans up after the animation.
  void (^cleanup)() = ^{
    [self.locationBarAnimatee setEditViewHidden:NO];
    [self.locationBarAnimatee setSteadyViewHidden:YES];
    [self.locationBarAnimatee resetTransforms];
    [self.locationBarAnimatee setSteadyViewFaded:NO];
    [self.locationBarAnimatee setEditViewFaded:NO];
    [self.editViewAnimatee setLeadingIconScale:1];
    [self.editViewAnimatee setClearButtonFaded:NO];
  };

  if (animated) {
    // Prepare for animation.
    BOOL shouldCrossfadeEditAndSteadyViews =
        _trigger != OmniboxFocusTrigger::kUnpinnedLargeFakebox &&
        _trigger != OmniboxFocusTrigger::kUnpinnedFakebox;
    if (shouldCrossfadeEditAndSteadyViews) {
      [self.locationBarAnimatee offsetTextFieldToMatchSteadyView];
      [self.locationBarAnimatee setEditViewFaded:YES];
    } else {
      // If focus trigger is the unpinned fakebox, the edit view will appear
      // in-place (without animation) and the steady view will not slide and
      // fade out - it will be hidden from the start.
      [self.locationBarAnimatee resetTextFieldOffsetAndOffsetSteadyViewToMatch];
      [self.locationBarAnimatee setEditViewFaded:NO];
      [self.locationBarAnimatee setSteadyViewFaded:YES];
    }

    // Hide badge and entrypoint views before the transform regardless of
    // current displayed state to prevent them from being visible outside of the
    // location bar as the steadView moves outside to the leading side of the
    // location bar.
    [self.locationBarAnimatee hideSteadyViewBadgeAndEntrypointViews];
    // Make edit view transparent, but not hidden.
    [self.locationBarAnimatee setEditViewHidden:NO];
    [self.editViewAnimatee setLeadingIconScale:0];
    [self.editViewAnimatee setClearButtonFaded:YES];

    self.inProgressAnimationCount += 1;
    [UIView animateKeyframesWithDuration:kMaterialDuration1
        delay:0
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          if (shouldCrossfadeEditAndSteadyViews) {
            [self.locationBarAnimatee
                    resetTextFieldOffsetAndOffsetSteadyViewToMatch];

            // Fading the views happens with a different timing for a better
            // visual effect. The steady view looks like an ordinary label, and
            // it fades before the animation is complete. The edit view will be
            // in pre-edit state, so it looks like selected text. Since the
            // selection is blue, it looks overwhelming if faded in at the same
            // time as the steady view. So it fades in faster and later into the
            // animation to look better.
            [UIView addKeyframeWithRelativeStartTime:0.1
                                    relativeDuration:0.8
                                          animations:^{
                                            [self.locationBarAnimatee
                                                setSteadyViewFaded:YES];
                                          }];

            [UIView addKeyframeWithRelativeStartTime:0.4
                                    relativeDuration:0.6
                                          animations:^{
                                            [self.locationBarAnimatee
                                                setEditViewFaded:NO];
                                          }];
          }

          // Scale the leading icon in with a slight bounce / spring.
          [UIView addKeyframeWithRelativeStartTime:0
                                  relativeDuration:0.75
                                        animations:^{
                                          [self.editViewAnimatee
                                              setLeadingIconScale:1.3];
                                        }];
          [UIView addKeyframeWithRelativeStartTime:0.75
                                  relativeDuration:0.25
                                        animations:^{
                                          [self.editViewAnimatee
                                              setLeadingIconScale:1];
                                          [self.editViewAnimatee
                                              setClearButtonFaded:NO];
                                        }];
        }
        completion:^(BOOL finished) {
          cleanup();
          [self animationFinished];
        }];
  } else {
    cleanup();

    if (_completion) {
      _completion();
      _completion = nil;
    }
  }
}

- (void)defocusOmniboxAnimated:(BOOL)animated {
  // Cleans up after the animation.
  void (^cleanup)() = ^{
    [self.locationBarAnimatee setEditViewHidden:YES];
    [self.locationBarAnimatee setSteadyViewHidden:NO];
    [self.locationBarAnimatee showSteadyViewBadgeAndEntrypointViews];
    [self.locationBarAnimatee resetTransforms];
    [self.locationBarAnimatee setSteadyViewFaded:NO];
    [self.editViewAnimatee setLeadingIconScale:1];
    [self.editViewAnimatee setClearButtonFaded:NO];
  };

  if (animated) {
    // Prepare for animation.
    [self.locationBarAnimatee offsetSteadyViewToMatchTextField];
    // Make steady view transparent, but not hidden.
    [self.locationBarAnimatee setSteadyViewHidden:NO];
    [self.locationBarAnimatee setSteadyViewFaded:YES];
    [self.editViewAnimatee setLeadingIconScale:1];
    [self.editViewAnimatee setClearButtonFaded:NO];
    CGFloat duration = kMaterialDuration1;

    self.inProgressAnimationCount += 1;
    [UIView animateWithDuration:duration
        delay:0
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          [self.locationBarAnimatee
                  resetSteadyViewOffsetAndOffsetTextFieldToMatch];
        }
        completion:^(BOOL finished) {
          cleanup();
          [self animationFinished];
        }];

    // These timings are explained in a comment in
    // focusOmniboxAnimated:shouldExpand:.
    self.inProgressAnimationCount += 1;
    [UIView animateWithDuration:0.2 * duration
        animations:^{
          [self.editViewAnimatee setLeadingIconScale:0];
          [self.editViewAnimatee setClearButtonFaded:YES];
        }
        completion:^(BOOL finished) {
          [self animationFinished];
        }];

    self.inProgressAnimationCount += 1;
    [UIView animateWithDuration:duration * 0.8
        delay:duration * 0.1
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          [self.locationBarAnimatee setEditViewFaded:YES];
        }
        completion:^(BOOL finished) {
          [self animationFinished];
        }];

    self.inProgressAnimationCount += 1;
    [UIView animateWithDuration:duration * 0.6
        delay:duration * 0.4
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          [self.locationBarAnimatee setSteadyViewFaded:NO];
        }
        completion:^(BOOL finished) {
          [self animationFinished];
        }];

  } else {
    cleanup();

    if (_completion) {
      _completion();
      _completion = nil;
    }
  }
}

// Updates the UI elements reflect the toolbar expanded state, `animated` or
// not.
- (void)updateUIToExpandedState:(BOOL)animated {
  if (animated) {
    // Use UIView animateWithDuration instead of UIViewPropertyAnimator to
    // avoid UIKit bug. See https://crbug.com/856155.
    self.inProgressAnimationCount += 1;
    if (IsIOSLargeFakeboxEnabled()) {
      // Set the location bar height to the default.
      [self.toolbarAnimatee setLocationBarHeightExpanded];
    }
    [self.toolbarAnimatee setToolbarFaded:NO];
    switch (_trigger) {
      case OmniboxFocusTrigger::kPinnedLargeFakebox:
        [self.toolbarAnimatee setLocationBarHeightToMatchFakeOmnibox];
        break;
      case OmniboxFocusTrigger::kUnpinnedLargeFakebox:
        [self.toolbarAnimatee setToolbarFaded:YES];
        break;
      default:
        break;
    }
    [UIView animateKeyframesWithDuration:kMaterialDuration1
        delay:0
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          [UIView addKeyframeWithRelativeStartTime:0
                                  relativeDuration:1
                                        animations:^{
                                          [self expansion];
                                        }];
          [UIView
              addKeyframeWithRelativeStartTime:0
                              relativeDuration:kMaterialDuration2 /
                                               kMaterialDuration1
                                    animations:^{
                                      [self.toolbarAnimatee hideControlButtons];
                                    }];
        }
        completion:^(BOOL finished) {
          [self animationFinished];
        }];

  } else {
    [self expansion];
    [self.toolbarAnimatee hideControlButtons];
  }
}

// Updates the UI elements reflect the toolbar contracted state, `animated` or
// not.
- (void)updateUIToContractedState:(BOOL)animated {
  if (animated) {
    // Use UIView animateWithDuration instead of UIViewPropertyAnimator to
    // avoid UIKit bug. See https://crbug.com/856155.
    CGFloat totalDuration = kMaterialDuration1 + kMaterialDuration2;
    CGFloat relativeDurationAnimation1 = kMaterialDuration1 / totalDuration;
    self.inProgressAnimationCount += 1;
    [UIView animateKeyframesWithDuration:totalDuration
        delay:0
        options:UIViewAnimationCurveEaseInOut
        animations:^{
          [UIView addKeyframeWithRelativeStartTime:0
                                  relativeDuration:relativeDurationAnimation1
                                        animations:^{
                                          [self contraction];
                                        }];
          [UIView
              addKeyframeWithRelativeStartTime:relativeDurationAnimation1
                              relativeDuration:1 - relativeDurationAnimation1
                                    animations:^{
                                      [self.toolbarAnimatee showControlButtons];
                                    }];
        }
        completion:^(BOOL finished) {
          [self.toolbarAnimatee hideCancelButton];
          [self animationFinished];
        }];
  } else {
    [self contraction];
    [self.toolbarAnimatee showControlButtons];
    [self.toolbarAnimatee hideCancelButton];
  }
}

- (void)animationFinished {
  self.inProgressAnimationCount -= 1;
  [self cleanupAfterAnimations];
}

- (void)cleanupAfterAnimations {
  // Make sure all the animations have been queued and finished.
  if (!self.areOmniboxChangesQueued || self.inProgressAnimationCount > 0) {
    return;
  }

  // inProgressAnimation count should never be negative because it should
  // always be incremented before starting an animation and decremented
  // when the animation finishes.
  DCHECK(self.inProgressAnimationCount == 0);

  self.isAnimating = NO;
  if (self.stateChangedDuringAnimation) {
    [self transitionToStateOmniboxFocused:self.finalOmniboxFocusedState
                          toolbarExpanded:self.finalToolbarExpandedState
                                  trigger:_trigger
                                 animated:NO
                               completion:_completion];
  } else {
    if (_completion) {
      _completion();
      _completion = nil;
    }
    if (IsIOSLargeFakeboxEnabled()) {
      // Reset the location bar height back to the default.
      [self.toolbarAnimatee setLocationBarHeightExpanded];
    }
  }
  self.stateChangedDuringAnimation = NO;
}

#pragma mark - Private animation helpers

// Visually expands the location bar for focus.
- (void)expansion {
  [self.toolbarAnimatee expandLocationBar];
  [self.toolbarAnimatee showCancelButton];
  switch (_trigger) {
    case OmniboxFocusTrigger::kPinnedLargeFakebox:
      [self.toolbarAnimatee setLocationBarHeightExpanded];
      break;
    case OmniboxFocusTrigger::kUnpinnedLargeFakebox:
      [self.toolbarAnimatee setToolbarFaded:NO];
      break;
    default:
      break;
  }
}

// Visually contracts the location bar for defocus.
- (void)contraction {
  [self.toolbarAnimatee contractLocationBar];
  if (_trigger == OmniboxFocusTrigger::kPinnedLargeFakebox) {
    [self.toolbarAnimatee setLocationBarHeightToMatchFakeOmnibox];
  }
}

@end