自定义视图在缩放后重绘时如何防止“反弹”效果?

Posted

技术标签:

【中文标题】自定义视图在缩放后重绘时如何防止“反弹”效果?【英文标题】:How to prevent "bounce" effect when a custom view redraws after zooming? 【发布时间】:2013-03-12 19:51:55 【问题描述】:

普通读者注意:尽管有标题,但这个问题与UIScrollView 属性bounces(与滚动相关)或bouncesZoom 无关。

我正在使用UIScrollView 为自定义视图添加缩放。自定义视图使用子图层来绘制其内容。每个子层都是一个CALayer 实例,它使用[CALayer addSublayer:] 添加到视图的主层。子层使用 CoreGraphics 来呈现它们的内容。

每次缩放完成后,自定义视图需要以新的缩放比例重新绘制其内容,以使内容再次显得清晰锐利。我目前正在尝试获取 this SO question 中显示的工作方法,即我在每次缩放操作后将滚动视图的 zoomScale 属性重置为 1.0,然后调整 minimumZoomScalemaximumZoomScale 属性,以便用户无法放大/缩小更多超出了最初的预期。

内容重绘已经正常工作(!),但我缺少的是平滑的 GUI 更新,以便缩放的内容被重绘到位而不会出现移动。使用我当前的解决方案(代码示例在这个问题的底部),我观察到一种“反弹”效果:缩放操作结束后,缩放的内容会短暂移动到不同的位置,然后立即移动回原来的位置位置。

我不完全确定“反弹”效果的原因是什么:要么有两个 GUI 更新周期(一个用于将 zoomScale 重置为 1.0,另一个用于 setNeedsDisplay),或者某种动画是发生使得这两个变化一个接一个地可见。

我的问题是:如何防止上述“反弹”效应?

更新:以下是一个最小但完整的代码示例,您可以简单地复制粘贴来观察我所说的效果。

    使用“空应用程序”模板创建一个新的 Xcode 项目。 将下面的代码分别添加到AppDelegate.hAppDelegate.m。 在项目的链接构建阶段,添加对QuartzCore.framework的引用。

进入AppDelegate.h的东西:

#import <UIKit/UIKit.h>

@class LayerView;

@interface AppDelegate : UIResponder <UIApplicationDelegate, UIScrollViewDelegate>
@property (nonatomic, retain) UIWindow* window;
@property (nonatomic, retain) LayerView* layerView;
@end

进入AppDelegate.m的东西:

#import "AppDelegate.h"
#import <QuartzCore/QuartzCore.h>

@class LayerDelegate;

@interface LayerView : UIView
@property (nonatomic, retain) LayerDelegate* layerDelegate;
@end

@interface LayerDelegate : NSObject
@property(nonatomic, retain) CALayer* layer;
@property (nonatomic, assign) CGFloat zoomScale;
@end

static CGFloat kMinimumZoomScale = 1.0;
static CGFloat kMaximumZoomScale = 5.0;

@implementation AppDelegate

- (void) dealloc

  self.window = nil;
  self.layerView = nil;
  [super dealloc];


- (BOOL) application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions

  [UIApplication sharedApplication].statusBarHidden = YES;
  self.window = [[[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]] autorelease];
  self.window.backgroundColor = [UIColor whiteColor];

  UIScrollView* scrollView = [[[UIScrollView alloc] initWithFrame:self.window.bounds] autorelease];
  [self.window addSubview:scrollView];
  scrollView.contentSize = scrollView.bounds.size;
  scrollView.delegate = self;
  scrollView.minimumZoomScale = kMinimumZoomScale;
  scrollView.maximumZoomScale = kMaximumZoomScale;
  scrollView.zoomScale = 1.0f;
  scrollView.bouncesZoom = NO;

  self.layerView = [[[LayerView alloc] initWithFrame:scrollView.bounds] autorelease];
  [scrollView addSubview:self.layerView];

  [self.window makeKeyAndVisible];
  return YES;


- (UIView*) viewForZoomingInScrollView:(UIScrollView*)scrollView

  return self.layerView;


- (void) scrollViewDidEndZooming:(UIScrollView*)scrollView withView:(UIView*)view atScale:(float)scale

  CGPoint contentOffset = scrollView.contentOffset;
  CGSize contentSize = scrollView.contentSize;

  scrollView.maximumZoomScale = scrollView.maximumZoomScale / scale;
  scrollView.minimumZoomScale = scrollView.minimumZoomScale / scale;
  // Big change here: This resets the scroll view's contentSize and
  // contentOffset, and also the LayerView's frame, bounds and transform
  // properties
  scrollView.zoomScale = 1.0f;

  CGFloat newZoomScale = self.layerView.layerDelegate.zoomScale * scale;
  self.layerView.layerDelegate.zoomScale = newZoomScale;

  self.layerView.frame = CGRectMake(0, 0, contentSize.width, contentSize.height);
  scrollView.contentSize = contentSize;
  [scrollView setContentOffset:contentOffset animated:NO];

  [self.layerView setNeedsDisplay];


@end

@implementation LayerView

- (id) initWithFrame:(CGRect)frame

  self = [super initWithFrame:frame];
  if (self)
  
    self.layerDelegate = [[[LayerDelegate alloc] init] autorelease];
    [self.layer addSublayer:self.layerDelegate.layer];
    // super's initWithFrame already invoked setNeedsDisplay, but we need to
    // repeat because at that time our layerDelegate property was still empty
    [self setNeedsDisplay];

  
  return self;


- (void) dealloc

  self.layerDelegate = nil;
  [super dealloc];


- (void) setNeedsDisplay

  [super setNeedsDisplay];
  // Zooming changes the view's frame, but not the frame of the layer
  self.layerDelegate.layer.frame = self.bounds;
  [self.layerDelegate.layer setNeedsDisplay];


@end

@implementation LayerDelegate

- (id) init

  self = [super init];
  if (self)
  
    self.layer = [CALayer layer];
    self.layer.delegate = self;
    self.zoomScale = 1.0f;
  
  return self;


- (void) dealloc

  self.layer = nil;
  [super dealloc];


- (void) drawLayer:(CALayer*)layer inContext:(CGContextRef)context

  CGRect layerRect = self.layer.bounds;
  CGFloat radius = 25 * self.zoomScale;
  CGFloat centerDistanceFromEdge = 5 * self.zoomScale + radius;

  CGPoint topLeftCenter = CGPointMake(CGRectGetMinX(layerRect) + centerDistanceFromEdge,
                                      CGRectGetMinY(layerRect) + centerDistanceFromEdge);
  [self drawCircleWithCenter:topLeftCenter radius:radius fillColor:[UIColor redColor] inContext:context];

  CGPoint layerCenter = CGPointMake(CGRectGetMidX(layerRect), CGRectGetMidY(layerRect));
  [self drawCircleWithCenter:layerCenter radius:radius fillColor:[UIColor greenColor] inContext:context];

  CGPoint bottomRightCenter = CGPointMake(CGRectGetMaxX(layerRect) - centerDistanceFromEdge,
                                          CGRectGetMaxY(layerRect) - centerDistanceFromEdge);
  [self drawCircleWithCenter:bottomRightCenter radius:radius fillColor:[UIColor blueColor] inContext:context];


- (void) drawCircleWithCenter:(CGPoint)center
                       radius:(CGFloat)radius
                    fillColor:(UIColor*)color
                    inContext:(CGContextRef)context

  const int startRadius = [self radians:0];
  const int endRadius = [self radians:360];
  const int clockwise = 0;
  CGContextAddArc(context, center.x, center.y, radius,
                  startRadius, endRadius, clockwise);
  CGContextSetFillColorWithColor(context, color.CGColor);
  CGContextFillPath(context);


- (double) radians:(double)degrees

  return degrees * M_PI / 180;


@end

【问题讨论】:

【参考方案1】:

根据您的示例项目,关键是您正在直接操作 CALayer。默认情况下,设置 CALayer 属性(例如 frame)会导致动画。使用[UIView setAnimationsEnabled:NO] 的建议是正确的,但只会影响基于 UIView 的动画。如果您执行 CALayer 等效项,请在您的 setNeedsDisplay: 方法中说:

[CATransaction begin];
[CATransaction setDisableActions:YES];
self.layerDelegate.layer.frame = self.bounds;
[CATransaction commit];

它可以防止隐式换帧动画,并且对我来说看起来很正确。您还可以通过 LayerDelegate 类中的 CALayerDelegate 方法禁用这些隐式动画:

- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event 
    return (id)[NSNull null]; // NSNull means "don't do any implicit animations"

原建议:

也许您在不知情的情况下处于动画块中?或者,也许您正在调用的方法之一是设置动画块?如果您在代码之前[UIView setAnimationsEnabled:NO] 并在之后重新启用它们会怎样?

如果它不是动画,那么它可能正如您所怀疑的那样;两种视图更新。 (也许一个来自滚动视图,另一个来自您的代码?)在这种情况下,一些可运行的示例代码会很棒。

(出于好奇,您是否尝试过使用 CALayer 的 shouldRasterize 和 rasterizationScale 而不是伪造缩放级别?)

【讨论】:

我尝试在滚动视图代理的scrollViewDidEndZooming 中调用[UIView setAnimationsEnabled:NO] 一次,并且在应用程序代理的didFinishLaunchingWithOptions 中也进行了很好的测量。唉,没有成功。关于光栅化:不,我没有尝试过你提到的那些属性,我不知道它们。在阅读了他们的描述之后,我必须承认我不知道如何在缩放后使用它们进行重绘 - 但我会尝试找出,我根本不关注重置缩放比例方法。最后,我将尽快修复一个最小但完整的代码示例。 如果您有时间再看看,我现在已将完整的代码示例添加到问题中。 @herzbube 太棒了,正在寻找。 @herzbube 对不起,应该戳你。我根据你的新代码更新了我的答案。 非常感谢,可惜我只能投票一次!你介意我在我的应用积分屏幕中提及你的名字吗?【参考方案2】:

在 X Code 用户界面构建器中有一个 Bounce 设置(位于 Scroll View 下)。

【讨论】:

不,不是这样,问题与UIScrollView属性bounces(滚动相关)无关。为了避免其他问题:它也与bouncesZoom无关。

以上是关于自定义视图在缩放后重绘时如何防止“反弹”效果?的主要内容,如果未能解决你的问题,请参考以下文章

.Net CF 防止过度、不耐烦的点击(屏幕重绘时)

iPhone:如何在捏缩放uiscrollview时重绘子视图

Android:自定义视图缩放/捏合

iOS:当视图中有两个小的不相邻区域需要重绘时,调用两次 setNeedsDisplayInRect 是不是更快?

用线程间隔重绘视图

SwiftUI:ObservableObject 在重绘时不会保持其状态