Files
NXKit/UIKitCore/lib/UIScrollView.cpp
T
2025-02-24 23:03:13 +01:00

431 lines
17 KiB
C++

//
// Created by Даниил Виноградов on 24.01.2025.
//
//
// UIScrollView.cpp
// SDLTest
//
// Created by Даниил Виноградов on 12.03.2023.
//
#include <UIScrollView.h>
#include <CATransaction.h>
#include <UIScrollViewExtensions/SpringTimingParameters.h>
#include <UIScrollViewExtensions/RubberBand.h>
namespace NXKit {
UIScrollView::UIScrollView(NXRect frame): UIView(frame) {
_panGestureRecognizer = new_shared<UIPanGestureRecognizer>();
_panGestureRecognizer->onStateChanged = [this](auto){ onPanGestureStateChanged(); };
addGestureRecognizer(_panGestureRecognizer);
setClipsToBounds(true);
// applyScrollIndicatorsStyle();
// [horizontalScrollIndicator, verticalScrollIndicator].forEach {
// $0.alpha = 0
// addSubview($0)
// }
}
//void UIScrollView::addSubview(std::shared_ptr<UIView> view) {
// UIView::addSubview(view);
// view->yoga->setPositionType(YGPositionTypeAbsolute);
//}
void UIScrollView::setContentOffset(NXPoint offset, bool animated) {
if (offset == contentOffset()) return;
// Cancel deceleration animations only when contentOffset gets set without animations.
// Otherwise we might cancel any "bounds" animations which are not iniated from velocity scrolling.
if (_isDecelerating && UIView::currentAnimationPrototype == nullptr) {
cancelDecelerationAnimations();
}
setBounds(NXRect(offset, bounds().size));
layoutScrollIndicatorsIfNeeded();
// otherwise everything subscribing to scrollViewDidScroll is implicitly animated from velocity scroll
CATransaction::begin();
CATransaction::setDisableActions(!animated);
if (!delegate.expired()) delegate.lock()->scrollViewDidScroll(shared_from_base<UIScrollView>());
CATransaction::commit();
}
void UIScrollView::setContentInsetAdjustmentBehavior(UIScrollViewContentInsetAdjustmentBehavior contentInsetAdjustmentBehavior) {
if (_contentInsetAdjustmentBehavior == contentInsetAdjustmentBehavior) return;
_contentInsetAdjustmentBehavior = contentInsetAdjustmentBehavior;
setNeedsLayout();
}
void UIScrollView::setBounceHorizontally(bool bounceHorizontally) {
if (_bounceHorizontally == bounceHorizontally) return;
_bounceHorizontally = bounceHorizontally;
setNeedsLayout();
}
void UIScrollView::setBounceVertically(bool bounceVertically) {
if (_bounceVertically == bounceVertically) return;
_bounceVertically = bounceVertically;
setNeedsLayout();
}
void UIScrollView::layoutMarginsDidChange() {
auto delta = _lastLayoutMargins - layoutMargins();
_lastLayoutMargins = layoutMargins();
auto target = getBoundsCheckedContentOffset(contentOffset() + NXPoint(delta.left, delta.top));
setContentOffset(target, false);
}
NXPoint UIScrollView::visibleContentOffset() {
return (layer()->presentationOrSelf())->bounds().origin;
}
NXSize UIScrollView::contentSize() {
if (subviews().empty()) return {};
return subviews().front()->bounds().size;
}
NXPoint UIScrollView::getBoundsCheckedContentOffset(NXPoint newContentOffset) {
auto contentNXSize = this->contentSize();
auto contentHeight = contentNXSize.height;// fmaxf(contentNXSize.height, bounds().height());
auto contentWidth = contentNXSize.width;// fmaxf(contentNXSize.width, bounds().width());
auto allInsects = _contentInset;// + layoutMargins();
bool contentWidthGreaterThenScrollBounds = contentWidth > bounds().width() -_contentInset.left - _contentInset.right;
bool contentHeightGreaterThenScrollBounds = contentHeight > bounds().height() - _contentInset.top - _contentInset.bottom;
switch (_contentInsetAdjustmentBehavior) {
case UIScrollViewContentInsetAdjustmentBehavior::scrollableAxes: {
if (contentWidthGreaterThenScrollBounds || _bounceHorizontally) {
allInsects += UIEdgeInsets(0, layoutMargins().left, 0, layoutMargins().right);
}
if (contentHeightGreaterThenScrollBounds || _bounceVertically) {
allInsects += UIEdgeInsets(layoutMargins().top, 0, layoutMargins().bottom, 0);
}
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::always: {
allInsects += layoutMargins();
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::never: {
break;
}
}
bool contentWidthGreaterThenScrollSafeArea = contentWidth > bounds().width() -allInsects.left - allInsects.right;
bool contentHeightGreaterThenScrollSafeArea = contentHeight > bounds().height() - allInsects.top - allInsects.bottom;
NXPoint target;
if (!contentWidthGreaterThenScrollSafeArea) {
target.x = - allInsects.left;
} else {
target.x = fminf(fmaxf(newContentOffset.x, -allInsects.left), (contentWidth + allInsects.right) - bounds().width());
}
if (!contentHeightGreaterThenScrollSafeArea) {
target.y = - allInsects.top;
} else {
target.y = fminf(fmaxf(newContentOffset.y, -allInsects.top), (contentHeight + allInsects.bottom) - bounds().height());
}
return target;
}
NXRect UIScrollView::contentOffsetBounds() {
auto contentNXSize = this->contentSize();
auto contentHeight = contentNXSize.height;// fmaxf(contentNXSize.height, bounds().height());
auto contentWidth = contentNXSize.width;// fmaxf(contentNXSize.width, bounds().width());
auto allInsects = _contentInset;// + layoutMargins();
bool contentWidthGreaterThenScrollBounds = contentWidth > bounds().width() -_contentInset.left - _contentInset.right;
bool contentHeightGreaterThenScrollBounds = contentHeight > bounds().height() - _contentInset.top - _contentInset.bottom;
switch (_contentInsetAdjustmentBehavior) {
case UIScrollViewContentInsetAdjustmentBehavior::scrollableAxes: {
if (contentWidthGreaterThenScrollBounds || _bounceHorizontally) {
allInsects += UIEdgeInsets(0, layoutMargins().left, 0, layoutMargins().right);
}
if (contentHeightGreaterThenScrollBounds || _bounceVertically) {
allInsects += UIEdgeInsets(layoutMargins().top, 0, layoutMargins().bottom, 0);
}
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::always: {
allInsects += layoutMargins();
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::never: {
break;
}
}
bool contentWidthGreaterThenScrollSafeArea = contentWidth > bounds().width() -allInsects.left - allInsects.right;
bool contentHeightGreaterThenScrollSafeArea = contentHeight > bounds().height() - allInsects.top - allInsects.bottom;
NXRect resBounds;
if (!contentWidthGreaterThenScrollSafeArea) {
resBounds.origin.x = - allInsects.left;
resBounds.size.width = 0;
} else {
resBounds.origin.x = - allInsects.left;
resBounds.size.width = (contentWidth + allInsects.left + allInsects.right) - bounds().width();
}
if (!contentHeightGreaterThenScrollSafeArea) {
resBounds.origin.y = - allInsects.top;
resBounds.size.height = 0;
} else {
resBounds.origin.y = - allInsects.top;
resBounds.size.height = (contentHeight + allInsects.top + allInsects.bottom) - bounds().height();
}
return resBounds;
}
bool UIScrollView::shouldBounceHorizontally() {
auto contentNXSize = this->contentSize();
bool contentWidthGreaterThenScrollBounds = contentNXSize.width > bounds().width() -_contentInset.left - _contentInset.right;
auto allInsects = _contentInset;// + layoutMargins();
switch (_contentInsetAdjustmentBehavior) {
case UIScrollViewContentInsetAdjustmentBehavior::scrollableAxes: {
if (contentWidthGreaterThenScrollBounds || _bounceHorizontally) {
allInsects += UIEdgeInsets(0, layoutMargins().left, 0, layoutMargins().right);
}
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::always: {
allInsects += layoutMargins();
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::never: {
break;
}
}
bool contentWidthGreaterThenScrollSafeArea = contentNXSize.width > bounds().width() -allInsects.left - allInsects.right;
return contentWidthGreaterThenScrollSafeArea && _bounceHorizontally;
}
bool UIScrollView::shouldBounceVertically() {
auto contentNXSize = this->contentSize();
bool contentHeightGreaterThenScrollBounds = contentNXSize.height > bounds().height() - _contentInset.top - _contentInset.bottom;
auto allInsects = _contentInset;// + layoutMargins();
switch (_contentInsetAdjustmentBehavior) {
case UIScrollViewContentInsetAdjustmentBehavior::scrollableAxes: {
if (contentHeightGreaterThenScrollBounds || _bounceVertically) {
allInsects += UIEdgeInsets(layoutMargins().top, 0, layoutMargins().bottom, 0);
}
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::always: {
allInsects += layoutMargins();
break;
}
case UIScrollViewContentInsetAdjustmentBehavior::never: {
break;
}
}
bool contentHeightGreaterThenScrollSafeArea = contentNXSize.height > bounds().height() - allInsects.top - allInsects.bottom;
return contentHeightGreaterThenScrollSafeArea && _bounceVertically;
}
void UIScrollView::onPan() {
auto translation = _panGestureRecognizer->translationInView(shared_from_this());
// _panGestureRecognizer->setTranslation(NXPoint(), shared_from_this());
auto panGestureVelocity = _panGestureRecognizer->velocityIn(shared_from_this());
weightedAverageVelocity = weightedAverageVelocity * 0.2 + panGestureVelocity * 0.8;
auto offset = contentOffsetBounds();
auto rubberBand = RubberBand(0.55f, frame().size, contentOffsetBounds());
NXPoint clamped = getBoundsCheckedContentOffset(_initialContentOffset - translation);
NXPoint target = rubberBand.clamp(_initialContentOffset - translation);
if (!shouldBounceHorizontally()) {
target.x = clamped.x;
}
if (!shouldBounceVertically()) {
target.y = clamped.y;
}
setContentOffset(target, false);
// auto newOffset = getBoundsCheckedContentOffset(_initialContentOffset - translation);
// setContentOffset(newOffset, false);
}
void UIScrollView::onPanGestureStateChanged() {
switch (_panGestureRecognizer->state()) {
case UIGestureRecognizerState::possible: {
// showScrollIndicators();
cancelDeceleratingIfNeccessary();
break;
}
case UIGestureRecognizerState::began: {
// printf("Began\n");
_initialContentOffset = contentOffset();
break;
}
case UIGestureRecognizerState::changed: {
if (!delegate.expired()) delegate.lock()->scrollViewWillBeginDragging(shared_from_base<UIScrollView>());
onPan();
break;
}
case UIGestureRecognizerState::ended: {
startDeceleratingIfNecessary();
weightedAverageVelocity = NXPoint();
break;
}
// XXX: Spring back with animation:
//case .ended, .cancelled:
//if contentOffset.x < _contentInset.left {
// setContentOffset(CGNXPoint(x: _contentInset.left, y: contentOffset.y), animated: true)
//}
default: break;
}
}
void UIScrollView::layoutScrollIndicatorsIfNeeded() {
}
void UIScrollView::showScrollIndicators() {
}
void UIScrollView::hideScrollIndicators() {
}
void UIScrollView::startDeceleratingIfNecessary() {
// Only animate if instantaneous velocity is large enough
// Otherwise we could animate after scrolling quickly, pausing for a few seconds, then letting go
// TODO: Need to check velocity, it's too powerful
auto velocity = NXPoint(-weightedAverageVelocity.x / 50, -weightedAverageVelocity.y / 50);
if (!shouldBounceVertically()) {
velocity.y = 0;
}
if (!shouldBounceHorizontally()) {
velocity.x = 0;
}
auto decelerationRate = _decelerationRate.rawValue();
auto threshold = 0.5f / traitCollection()->displayScale();// layer()->contentsScale();
auto parameters = DecelerationTimingParameters(contentOffset(),velocity, decelerationRate, threshold);
auto destination = parameters.destination();
auto clippedDestination = getBoundsCheckedContentOffset(destination);
bool isClipped = destination != clippedDestination;
float duration;
if (isClipped) {
duration = parameters.durationTo(clippedDestination);
} else {
duration = parameters.duration();
}
_isDecelerating = true;
_timerAnimation = std::make_shared<TimerAnimation>(duration, [this, parameters](float, double time) {
setContentOffset(parameters.valueAt(time), false);
}, [this, parameters, duration, isClipped](bool) {
_isDecelerating = isClipped;
if (isClipped) {
auto velocity = parameters.velocityAt(duration);
bounceWithVelocity(velocity);
}
});
// auto nonBoundsCheckedScrollAnimationDistance = weightedAverageVelocity * dampingFactor; // hand-tuned
// auto targetOffset = contentOffset() - nonBoundsCheckedScrollAnimationDistance;// getBoundsCheckedContentOffset(contentOffset() - nonBoundsCheckedScrollAnimationDistance);
// auto distanceToBoundsCheckedTarget = contentOffset() - targetOffset;
// auto velocityIsLargeEnoughToDecelerate = (velocity.magnitude() > 10);
// auto willDecelerate = (velocityIsLargeEnoughToDecelerate && distanceToBoundsCheckedTarget.magnitude() > 0.0);
//
// if (!delegate.expired()) delegate.lock()->scrollViewDidEndDragging(shared_from_base<UIScrollView>(), willDecelerate);
// if (!willDecelerate) { hideScrollIndicators(); return; }
//
// // https://ariya.io/2011/10/flick-list-with-its-momentum-scrolling-and-deceleration
// // TODO: This value should be calculated from `self.decelerationRate` instead
// // But actually we want to redo this function to avoid `UIView.animate` entirely,
// // in which case we wouldn't need an animationTime at all.
// auto animationTimeConstant = 0.325f * dampingFactor;
//
// // This calculation is a weird approximation but it's close enough for now...
// auto animationTime = logf(distanceToBoundsCheckedTarget.magnitude()) * animationTimeConstant;
//
// UIViewAnimationOptions options = UIViewAnimationOptions(UIViewAnimationOptions::beginFromCurrentState | UIViewAnimationOptions::customEaseOut | UIViewAnimationOptions::allowUserInteraction);
// UIView::animate(
// animationTime,
// 0,
// options,
// [this, targetOffset]() {
// _isDecelerating = true;
// setContentOffset(targetOffset, false);
// },
// [this](bool) {
// _isDecelerating = false;
// }
// );
}
void UIScrollView::bounceWithVelocity(NXPoint velocity) {
auto restOffset = getBoundsCheckedContentOffset(contentOffset());
auto displacement = contentOffset() - restOffset;
auto threshold = 0.5f / traitCollection()->displayScale(); //layer()->contentsScale();
auto spring = Spring(1, 100, 1);
auto parameters = SpringTimingParameters(spring, displacement,velocity, threshold);
auto duration = parameters.duration();
_timerAnimation = std::make_shared<TimerAnimation>(duration, [this, restOffset, parameters](auto, float time){
setContentOffset(restOffset + parameters.valueAt(time), false);
}, [this](bool) {
_isDecelerating = false;
});
}
void UIScrollView::cancelDeceleratingIfNeccessary() {
if (!_isDecelerating) { return; }
// Get the presentation value from the current animation
if (_timerAnimation) _timerAnimation->invalidate();
setContentOffset(visibleContentOffset(), false);
cancelDecelerationAnimations();
_isDecelerating = false;
}
void UIScrollView::cancelDecelerationAnimations() {
// if (!layer()->animations.isEmpty) {
// layer.removeAnimation(forKey: "bounds")
// horizontalScrollIndicator.layer.removeAnimation(forKey: "position")
// verticalScrollIndicator.layer.removeAnimation(forKey: "position")
// }
layer()->removeAnimation("bounds");
}
//bool UIScrollView::applyXMLAttribute(std::string name, std::string value) {
// if (UIView::applyXMLAttribute(name, value)) { return true; }
//
// REGISTER_XIB_ATTRIBUTE(bounceVertically, valueToBool, setBounceVertically)
// REGISTER_XIB_ATTRIBUTE(bounceHorizontally, valueToBool, setBounceHorizontally)
//
// return false;
//}
void UIScrollView::layoutSubviews() {
UIView::layoutSubviews();
// setContentOffset(getBoundsCheckedContentOffset(contentOffset()), false);
}
}