I have a prototype cell with a custom class that I want to draw a few extra layers on the cell once when the cell is first initialized and not every time the cell is reused. In the past I would have done this by implementing awakeFromNib. I want to be able to access the frame of the views in my cell so I can use their dimensions in my new layer drawings, but with iOS6 the subviews all have frame width/height of 0 in the awakeFromNib method. I suspect it has to do with the new constraints layout stuff which I don't really understand yet.
- (void)awakeFromNib {
[super awakeFromNib];
// We only want to draw this dotted line once
CGPoint start = CGPointZero;
CGPoint end = CGPointMake(self.horizontalSeparator.frame.size.width, 0);
// Category that creates a layer with a dotted line and adds it to the view.
[self.horizontalSeparator addDottedLine:start to:end];
}
In awakeFromNib the horizontalSeparator.frame = (0 100; 0 0). How can I draw this dotted line layer once per cell and use the width of the existing horizontalSeparator view to determine the length of the line?
UPDATE
I figured out that I can use the constraints on the superview to figure out the dimensions on the subviews, but I'm still hoping someone can point me towards a better solution that doesn't make assumptions about the constraint configuration.
for (NSLayoutConstraint *constraint in self.constraints) {
if (// Find a constraint for the horizontalSeparator
(constraint.firstItem == self.horizontalSeparator
|| constraint.secondItem == self.horizontalSeparator)
&& // Make sure it affects the leading or trailing edge.
(constraint.firstAttribute == NSLayoutAttributeLeading
|| constraint.firstAttribute == NSLayoutAttributeTrailing)) {
CGFloat margin = constraint.constant;
CGPoint start = CGPointZero;
CGPoint end = CGPointMake(self.frame.size.width - (2 * margin), 0);
[self.horizontalSeparator addDottedLine:start to:end];
_isInitialized = YES;
break;
}
}
Related
How can I resize views with a separator? What I'm trying to do is something like Instagram layout app. I want to be able to resize views by dragging the line that separates the views.
I already looked into this question. It is similar to what I want to accomplish and I already tried the answers but it does not work if there are more than 2 views connected to a separator (if there are 3 or more view only 2 views resize when separator moves each time). I tried to change the code but I have no idea what to do or what the code means.
In my app I will have 2-6 views. The separator should resize all the views that is next to it.
Some examples of my views:
How can I accomplish this? Where do I start?
There are lots of ways to accomplish this, but like Avinash, I'd suggest creating a "separator view" in between the various "content" UIView objects. Then you can drag that around. The trick here, though, is that you likely want the separator view to be bigger than just the narrow visible line, so that it will capture touches not only right on the separator line, but close to it, too.
Unlike that other answer you reference, nowadays I'd new recommend using autolayout so that all you need to do with the user gestures is update the location of the separator view (e.g. update the top constraint of the separator view), and then all of the other views will be automatically resized for you. I'd also suggest adding a low priority constraint on the size of the subviews, so that they're laid out nicely when you first set everything up and before you start dragging separators around, but that it will fail gracefully when the dragged separator dictates that the size of the neighboring views must change.
Finally, while we'd historically use gesture recognizers for stuff like this, with the advent of predicted touches in iOS 9, I'd suggest just implementing touchesBegan, touchesMoved, etc. Using predicted touches, you won't notice the difference on the simulator or older devices, but when you run this on a device capable of predicted touches (e.g. new devices like the iPad Pro and other new devices), you'll get a more responsive UX.
So a horizontal separator view class might look like the following.
static CGFloat const kTotalHeight = 44; // the total height of the separator (including parts that are not visible
static CGFloat const kVisibleHeight = 2; // the height of the visible portion of the separator
static CGFloat const kMargin = (kTotalHeight - kVisibleHeight) / 2.0; // the height of the non-visible portions of the separator (i.e. above and below the visible portion)
static CGFloat const kMinHeight = 10; // the minimum height allowed for views above and below the separator
/** Horizontal separator view
#note This renders a separator view, but the view is larger than the visible separator
line that you see on the device so that it can receive touches when the user starts
touching very near the visible separator. You always want to allow some margin when
trying to touch something very narrow, such as a separator line.
*/
#interface HorizontalSeparatorView : UIView
#property (nonatomic, strong) NSLayoutConstraint *topConstraint; // the constraint that dictates the vertical position of the separator
#property (nonatomic, weak) UIView *firstView; // the view above the separator
#property (nonatomic, weak) UIView *secondView; // the view below the separator
// some properties used for handling the touches
#property (nonatomic) CGFloat oldY; // the position of the separator before the gesture started
#property (nonatomic) CGPoint firstTouch; // the position where the drag gesture started
#end
#implementation HorizontalSeparatorView
#pragma mark - Configuration
/** Add a separator between views
This creates the separator view; adds it to the view hierarchy; adds the constraint for height;
adds the constraints for leading/trailing with respect to its superview; and adds the constraints
the relation to the views above and below
#param firstView The UIView above the separator
#param secondView The UIView below the separator
#returns The separator UIView
*/
+ (instancetype)addSeparatorBetweenView:(UIView *)firstView secondView:(UIView *)secondView {
HorizontalSeparatorView *separator = [[self alloc] init];
[firstView.superview addSubview:separator];
separator.firstView = firstView;
separator.secondView = secondView;
[NSLayoutConstraint activateConstraints:#[
[separator.heightAnchor constraintEqualToConstant:kTotalHeight],
[separator.superview.leadingAnchor constraintEqualToAnchor:separator.leadingAnchor],
[separator.superview.trailingAnchor constraintEqualToAnchor:separator.trailingAnchor],
[firstView.bottomAnchor constraintEqualToAnchor:separator.topAnchor constant:kMargin],
[secondView.topAnchor constraintEqualToAnchor:separator.bottomAnchor constant:-kMargin],
]];
separator.topConstraint = [separator.topAnchor constraintEqualToAnchor:separator.superview.topAnchor constant:0]; // it doesn't matter what the constant is, because it hasn't been enabled
return separator;
}
- (instancetype)init {
self = [super init];
if (self) {
self.translatesAutoresizingMaskIntoConstraints = false;
self.userInteractionEnabled = true;
self.backgroundColor = [UIColor clearColor];
}
return self;
}
#pragma mark - Handle Touches
// When it first receives touches, save (a) where the view currently is; and (b) where the touch started
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.oldY = self.frame.origin.y;
self.firstTouch = [[touches anyObject] locationInView:self.superview];
self.topConstraint.constant = self.oldY;
self.topConstraint.active = true;
}
// When user drags finger, figure out what the new top constraint should be
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
UITouch *touch = [touches anyObject];
// for more responsive UX, use predicted touches, if possible
if ([UIEvent instancesRespondToSelector:#selector(predictedTouchesForTouch:)]) {
UITouch *predictedTouch = [[event predictedTouchesForTouch:touch] lastObject];
if (predictedTouch) {
[self updateTopConstraintOnBasisOfTouch:predictedTouch];
return;
}
}
// if no predicted touch found, just use the touch provided
[self updateTopConstraintOnBasisOfTouch:touch];
}
// When touches are done, reset constraint on the basis of the final touch,
// (backing out any adjustment previously done with predicted touches, if any).
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self updateTopConstraintOnBasisOfTouch:[touches anyObject]];
}
/** Update top constraint of the separator view on the basis of a touch.
This updates the top constraint of the horizontal separator (which moves the visible separator).
Please note that this uses properties populated in touchesBegan, notably the `oldY` (where the
separator was before the touches began) and `firstTouch` (where these touches began).
#param touch The touch that dictates to where the separator should be moved.
*/
- (void)updateTopConstraintOnBasisOfTouch:(UITouch *)touch {
// calculate where separator should be moved to
CGFloat y = self.oldY + [touch locationInView:self.superview].y - self.firstTouch.y;
// make sure the views above and below are not too small
y = MAX(y, self.firstView.frame.origin.y + kMinHeight - kMargin);
y = MIN(y, self.secondView.frame.origin.y + self.secondView.frame.size.height - (kMargin + kMinHeight));
// set constraint
self.topConstraint.constant = y;
}
#pragma mark - Drawing
- (void)drawRect:(CGRect)rect {
CGRect separatorRect = CGRectMake(0, kMargin, self.bounds.size.width, kVisibleHeight);
UIBezierPath *path = [UIBezierPath bezierPathWithRect:separatorRect];
[[UIColor blackColor] set];
[path stroke];
[path fill];
}
#end
A vertical separator would probably look very similar, but I'll leave that exercise for you.
Anyway, you could use it like so:
#implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
UIView *previousContentView = nil;
for (NSInteger i = 0; i < 4; i++) {
UIView *contentView = [self addRandomColoredView];
[self.view.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor].active = true;
[self.view.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor].active = true;
if (previousContentView) {
[HorizontalSeparatorView addSeparatorBetweenView:previousContentView secondView:contentView];
NSLayoutConstraint *height = [contentView.heightAnchor constraintEqualToAnchor:previousContentView.heightAnchor];
height.priority = 250;
height.active = true;
} else {
[self.view.topAnchor constraintEqualToAnchor:contentView.topAnchor].active = true;
}
previousContentView = contentView;
}
[self.view.bottomAnchor constraintEqualToAnchor:previousContentView.bottomAnchor].active = true;
}
- (UIView *)addRandomColoredView {
UIView *someView = [[UIView alloc] init];
someView.translatesAutoresizingMaskIntoConstraints = false;
someView.backgroundColor = [UIColor colorWithRed:arc4random_uniform(256)/255.0 green:arc4random_uniform(256)/255.0 blue:arc4random_uniform(256)/255.0 alpha:1.0];
[self.view addSubview:someView];
return someView;
}
#end
That yields something like:
As I mentioned, a vertical separator would look very similar. If you have complicated views with both vertical and horizontal separators, you'd probably want to have invisible container views to isolate the vertical and horizontal views. For example, consider one of your examples:
That would probably consist of two views that span the entire width of the device with a single horizontal separator, and then the top view would, itself, have two subviews with one vertical separator and the bottom view would have three subviews with two vertical separators.
There's a lot here, so before you try extrapolating the above example to handle (a) vertical separators; and then (b) the views-within-views pattern, make sure you really understand how the above example works. This isn't intended as a generalized solution, but rather just to illustrate a pattern you might adopt. But hopefully this illustrates the basic idea.
I've updated #JULIIncognito's Swift class to Swift 4, added a drag indicator and fixed some typos.
SeparatorView
Just import in into your project and use it like so:
SeparatorView.addSeparatorBetweenViews(separatorType: .horizontal, primaryView: view1, secondaryView: view2, parentView: self.view)
This is how it looks like (MapView on top, TableView on bottom):
Base on Rob`s solution I created Swift class for both horizontal and vertical separator view:
https://gist.github.com/JULI-ya/1a7c293b022207bb427caa3bbb9d3ed8
There are code only for two inner views with separator, because my idea is to put each to other for creating this custom layout. It will look like a binary tree structure of views.
Use UIPanGestureRecognizers. Add a recognizer to each view. In gestureRecognizerShouldBegin: method return YES if the location of gesture is very close to the edge (use gesture's locationInView:view method). Then in gesture's action method (specified in gesture's initWithTarget: action:) you proccess your moves something like this:
-(void)viewPan:(UIPanGestureRecognizer *)sender
switch (sender.state) {
case UIGestureRecognizerStateBegan: {
//determine the second view based on gesture's locationInView:
//for instance if close to bottom, the second view is the one under the current.
}
case UIGestureRecognizerStateChanged: {
//change the frames of the current and the second view based on sender's translationInView:
}
...
}
As per my best knowledge we can do this using UIGestureRecognizer and auto layout.
1. Use UIView as line separator.
2. Add Pan gestureRecognizer to separator line view.
3. Handle view movement in delegate protocol methods using UIView.animatewithDuration()
PanGestureRecognizer
Most important, don't forget to set/Check UserInteration Enabled for all line separator view in Attribute Inspector.
I have several subviews which are laid out based on the size of their super view. And I use auto layout here but the size of the super view is always 0, here is the code:
- (instancetype)initWithFrame:(CGRect)frame Count:(NSUInteger)count
{
self = [super initWithFrame:frame];
if (self)
{
self.count = count;
const float circleHeight = self.bounds.size.height * (float)4 / (5 * self.count - 1);
NSLog(#"selfHeight %f",self.bounds.size.height);
for (UIImageView * circle in self.circleViewArray)
{
[self addSubview:circle];
}
for (int i = 0;i < self.count;i++)
{
[self.circleViewArray[i] mas_makeConstraints:^(MASConstraintMaker * make){
make.width.equalTo(self);
make.height.equalTo(self).multipliedBy((float)4 / (5 * self.count - 1));
make.centerX.equalTo(self);
make.bottom.equalTo(self).with.offset(-i * (circleHeight + stickHeight));
}];
}
}
return self;
}
Note that here I use the third-party Masonry to simplify my code.
When I print the "selfHeight" in the console the output is always 0. How should I handle it?
So your offsets are never going to work because they're calculated against zero and never updated. You need to make the constraints relative somehow, or you need to remove and update the constraints each time the frame changes (the layout needs to be updated).
Making the constraints relative is better, which in your case you should look at linking the views together so the spacing between yhen is set and the heights resize to fit the full available height.
Pseudocode:
UIView *previousView = nil;
for (view in views) {
view.leading = super.leading;
view.trailing = super.trailing;
if (previousView) {
view.top = previousView.bottom;
view.height = previousView.height; // equal relation, not static height
} else {
view.top = super.top;
}
previousView = view;
}
previousView.bottom = super.bottom;
I would like to achieve which I'll explain with an example described as follow.
In MS Word, in a canvas, we can add several objects like arrow, circle, square etc. Then we can easily group them. Once objects are properly grouped, now matter how we scale, it is scaled as a group & gives us a perfect scaling. I wish to achieve same using Autolayout in Storyboard / xib. According to me all objects scales based on their individual constraints.
Here is an example image.
Here is the structure of above image.
View with white color background
View with gray color background
View with white color bg and on top-center
View with white color bg and in center-center
View with white bg and bottom left
View with white bg and bottom right
If the top-most parent view is scaled down, every other elements in it recursively should get scaled down.
How to achieve such functionality using Autolayout?
Edit:
Here - I've prepared a quick gif for the same.
You can do this by using the multiplier part of the constraint system.
Each constraint is just an equation...
calculatedSize = multiplier * inputSize + constant
The calculatedSize is the size of your view or the distance from the edge etc...
The inputSize (when used) is the size of another view when (for example) using equal widths etc...
In order to get the behaviour you are after you need the calculatedSize to always be a multiple of the inputSize and have the constant set to 0.
So, in your example animation. If the grey square is set to have a width equal to the group width * 0.5 then it will always scale properly.
That is fine for the sizes. The gaps can't work like this though.
In order to get the gaps to scale properly you can't just have them as gaps. Gaps are not "objects" in the language of AutoLayout. You can't make one gap equal to another. Nor can you make a gap related to the width of another view.
What you can do though is replace the gaps with "spacer" views. These are actual UIViews that you add to your storyboard but then you make them hidden so that you can't see them at runtime.
So, going back to the grey square in the example you might have something like...
|[spacerView][greySquare]
Now set the width of the spacerView to be superView.width * 0.17 and the width of the greySquare to be superView.width * 0.5.
// using the equation above
greySquare.width = superView.width * 0.5 + 0
spacerView.width = superView.width * 0.17 + 0
You can set the spacerView to hidden either in Interface Builder (this will hide it at design time) or in a runtime attribute (this will hide it at runtime).
Now when you resize the superView the spacer will grow relative to the amount you have grown the superView.
This can get quite complex though as you still need to make sure you have all the spacer views constrained properly.
I found a solution for this problem but maybe is not the best. The idea is to change the Width and Height of the root view, calculate the percentage of Width and Height variation from the initial state and apply this to all children constraint.
In your Storyboard create a root UIView and place all UIView children inside.
You need to set 4 constraints per child and for the root View: Width, Height, Vertical space and Horizontal space.
Connect the root view, width constraint and height constraint to the UIViewController.
Code
#import "ViewController.h"
#interface ViewController ()
#property (strong, nonatomic) IBOutlet NSLayoutConstraint *heightConstraint;
#property (strong, nonatomic) IBOutlet NSLayoutConstraint *widthConstraint;
#property (strong, nonatomic) IBOutlet UIView *rootView;
#end
#implementation ViewController{
CGPoint dragPoint;
CGFloat startDragWidth;
CGFloat startDragHeight;
NSMutableDictionary *startDragConstraints;
}
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view, typically from a nib.
UIPanGestureRecognizer *panRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:#selector(detectPan:)];
self.rootView.gestureRecognizers = #[panRecognizer];
}
- (void)didReceiveMemoryWarning {
[super didReceiveMemoryWarning];
// Dispose of any resources that can be recreated.
}
- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
startDragHeight = self.heightConstraint.constant;
startDragWidth = self.widthConstraint.constant;
startDragConstraints = [NSMutableDictionary dictionary];
//I used i and j for setting an identifier on the constraint
for (int i = 0; i < [self.rootView.subviews count]; i++)
{
UIView *view = self.rootView.subviews[i];
//Looping childs Width and Height constraints
for(int j = 0; j < [view.constraints count]; j++)
{
NSLayoutConstraint *constraint = view.constraints[j];
constraint.identifier = [NSString stringWithFormat:#"%d%d", i, j];
[startDragConstraints setObject:[NSNumber numberWithFloat:constraint.constant] forKey:constraint.identifier];
}
}
//Looping childs Vertical space and Horizontal space constraints. They live in the parent view
for (int i = 0; i < [self.rootView.constraints count]; i++)
{
NSLayoutConstraint *constraint = self.rootView.constraints[i];
constraint.identifier = [NSString stringWithFormat:#"%d", i];
[startDragConstraints setObject:[NSNumber numberWithFloat:constraint.constant] forKey:constraint.identifier];
}
}
- (void) detectPan:(UIPanGestureRecognizer *) uiPanGestureRecognizer
{
dragPoint = [uiPanGestureRecognizer translationInView:self.rootView];
if(fabsf(dragPoint.x) > fabsf(dragPoint.y))
{
dragPoint.y = dragPoint.x;
}
else
{
dragPoint.x = dragPoint.y;
}
self.heightConstraint.constant = startDragHeight + dragPoint.y;
self.widthConstraint.constant = startDragWidth + dragPoint.x;
CGFloat heightDelta = (self.heightConstraint.constant - startDragHeight) / startDragHeight;
CGFloat widthDelta = (self.widthConstraint.constant - startDragWidth) / startDragWidth;
for(UIView *view in self.rootView.subviews)
{
for(NSLayoutConstraint *constraint in view.constraints)
{
float previousConstant = [[startDragConstraints objectForKey:constraint.identifier] floatValue];
if(constraint.firstAttribute == NSLayoutAttributeWidth) //Width constraint
{
constraint.constant = previousConstant + previousConstant * widthDelta;
}
else if(constraint.firstAttribute == NSLayoutAttributeHeight) //Height constraint
{
constraint.constant = previousConstant + previousConstant * heightDelta;
}
}
}
for(NSLayoutConstraint *constraint in self.rootView.constraints)
{
float previousConstant = [[startDragConstraints objectForKey:constraint.identifier] floatValue];
if(constraint.firstAttribute == NSLayoutAttributeLeading) //Horizontal space constraint
{
constraint.constant = previousConstant + previousConstant * widthDelta;
}
else if(constraint.firstAttribute == NSLayoutAttributeTop) //Vertical space constraint
{
constraint.constant = previousConstant + previousConstant * heightDelta;
}
}
}
#end
I'm having a lot of trouble figuring out how best to reposition my UIScrollView's image view (I have a gallery kind of app going right now, similar to Photos.app, specifically when you're viewing a single image) when the orientation switches from portrait to landscape or vice-versa.
I know my best bet is to manipulate the contentOffset property, but I'm not sure what it should be changed to.
I've played around a lot, and it seems like for whatever reason 128 works really well. In my viewWillLayoutSubviews method for my view controller I have:
if (UIInterfaceOrientationIsLandscape([UIApplication sharedApplication].statusBarOrientation)) {
CGPoint newContentOffset = self.scrollView.contentOffset;
if (newContentOffset.x >= 128) {
newContentOffset.x -= 128.0;
}
else {
newContentOffset.x = 0.0;
}
newContentOffset.y += 128.0;
self.scrollView.contentOffset = newContentOffset;
}
else {
CGPoint newContentOffset = self.scrollView.contentOffset;
if (newContentOffset.y >= 128) {
newContentOffset.y -= 128.0;
}
else {
newContentOffset.y = 0.0;
}
newContentOffset.x += 128.0;
self.scrollView.contentOffset = newContentOffset;
}
And it works pretty well. I hate how it's using a magic number though, and I have no idea where this would come from.
Also, whenever I zoom the image I have it set to stay centred (just like Photos.app does):
- (void)centerScrollViewContent {
// Keep image view centered as user zooms
CGRect newImageViewFrame = self.imageView.frame;
// Center horizontally
if (newImageViewFrame.size.width < CGRectGetWidth(self.scrollView.bounds)) {
newImageViewFrame.origin.x = (CGRectGetWidth(self.scrollView.bounds) - CGRectGetWidth(self.imageView.frame)) / 2;
}
else {
newImageViewFrame.origin.x = 0;
}
// Center vertically
if (newImageViewFrame.size.height < CGRectGetHeight(self.scrollView.bounds)) {
newImageViewFrame.origin.y = (CGRectGetHeight(self.scrollView.bounds) - CGRectGetHeight(self.imageView.frame)) / 2;
}
else {
newImageViewFrame.origin.y = 0;
}
self.imageView.frame = newImageViewFrame;
}
So I need it to keep it positioned properly so it doesn't show black borders around the image when repositioned. (That's what the checks in the first block of code are for.)
Basically, I'm curious how to implement functionality like in Photos.app, where on rotate the scrollview intelligently repositions the content so that the middle of the visible content before the rotation is the same post-rotation, so it feels continuous.
You should change the UIScrollView's contentOffset property whenever the scrollView is layouting its subviews after its bounds value has been changed. Then when the interface orientation will be changed, UIScrollView's bounds will be changed accordingly updating the contentOffset.
To make things "right" you should subclass UIScrollView and make all the adjustments there. This will also allow you to easily reuse your "special" scrollView.
The contentOffset calculation function should be placed inside UIScrollView's layoutSubviews method. The problem is that this method is called not only when the bounds value is changed but also when srollView is zoomed or scrolled. So the bounds value should be tracked to hint if the layoutSubviews method is called due to a change in bounds as a consequence of the orientation change, or due to a pan or pinch gesture.
So the first part of the UIScrollView subclass should look like this:
- (id)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
// Set the prevBoundsSize to the initial bounds, so the first time
// layoutSubviews is called we won't do any contentOffset adjustments
self.prevBoundsSize = self.bounds.size;
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
if (!CGSizeEqualToSize(self.prevBoundsSize, self.bounds.size)) {
[self _adjustContentOffset];
self.prevBoundsSize = self.bounds.size;
}
[self _centerScrollViewContent];
}
Here, the layoutSubviews method is called every time the UIScrollView is panned, zoomed or its bounds are changed. The _centerScrollViewContent method is responsible for centering the zoomed view when its size becomes smaller than the size of the scrollView's bounds. And, it is called every time user pans or zooms the scrollView, or rotates the device. Its implementation is very similar to the implementation you provided in your question. The difference is that this method is written in the context of UIScrollView class and therefore instead of using self.imageView property to reference the zoomed view, which may not be available in the context of UIScrollView class, the viewForZoomingInScrollView: delegate method is used.
- (void)_centerScrollViewContent {
if ([self.delegate respondsToSelector:#selector(viewForZoomingInScrollView:)]) {
UIView *zoomView = [self.delegate viewForZoomingInScrollView:self];
CGRect frame = zoomView.frame;
if (self.contentSize.width < self.bounds.size.width) {
frame.origin.x = roundf((self.bounds.size.width - self.contentSize.width) / 2);
} else {
frame.origin.x = 0;
}
if (self.contentSize.height < self.bounds.size.height) {
frame.origin.y = roundf((self.bounds.size.height - self.contentSize.height) / 2);
} else {
frame.origin.y = 0;
}
zoomView.frame = frame;
}
}
But the more important thing here is the _adjustContentOffset method. This method is responsible for adjusting the contentOffset. Such that when UIScrollView's bounds value is changed the center point before the change will remain in center. And because of the condition statement, it is called only when UIScrollView's bounds is changed (e.g.: orientation change).
- (void)_adjustContentOffset {
if ([self.delegate respondsToSelector:#selector(viewForZoomingInScrollView:)]) {
UIView *zoomView = [self.delegate viewForZoomingInScrollView:self];
// Using contentOffset and bounds values before the bounds were changed (e.g.: interface orientation change),
// find the visible center point in the unscaled coordinate space of the zooming view.
CGPoint prevCenterPoint = (CGPoint){
.x = (self.prevContentOffset.x + roundf(self.prevBoundsSize.width / 2) - zoomView.frame.origin.x) / self.zoomScale,
.y = (self.prevContentOffset.y + roundf(self.prevBoundsSize.height / 2) - zoomView.frame.origin.y) / self.zoomScale,
};
// Here you can change zoomScale if required
// [self _changeZoomScaleIfNeeded];
// Calculate new contentOffset using the previously calculated center point and the new contentOffset and bounds values.
CGPoint contentOffset = CGPointMake(0.0, 0.0);
CGRect frame = zoomView.frame;
if (self.contentSize.width > self.bounds.size.width) {
frame.origin.x = 0;
contentOffset.x = prevCenterPoint.x * self.zoomScale - roundf(self.bounds.size.width / 2);
if (contentOffset.x < 0) {
contentOffset.x = 0;
} else if (contentOffset.x > self.contentSize.width - self.bounds.size.width) {
contentOffset.x = self.contentSize.width - self.bounds.size.width;
}
}
if (self.contentSize.height > self.bounds.size.height) {
frame.origin.y = 0;
contentOffset.y = prevCenterPoint.y * self.zoomScale - roundf(self.bounds.size.height / 2);
if (contentOffset.y < 0) {
contentOffset.y = 0;
} else if (contentOffset.y > self.contentSize.height - self.bounds.size.height) {
contentOffset.y = self.contentSize.height - self.bounds.size.height;
}
}
zoomView.frame = frame;
self.contentOffset = contentOffset;
}
}
Bonus
I've created a working SMScrollView class (here is link to GitHub) implementing the above behavior and additional bonuses:
You can notice that in Photos app, zooming a photo, then scrolling it to one of its boundaries and then rotating the device does not keep the center point in its place. Instead it sticks the scrollView to that boundary. And if you scroll to one of the corners and then rotate, the scrollView will be stick to that corner as well.
In addition to adjusting contentOffset you may find that you also want to adjust the scrollView's zoomScale. For example, assume you are viewing a photo in portrait mode that is scaled to fit the screen size. Then when you rotate the device to the landscape mode you may want to upscale the photo to take advantage of the available space.
I'm trying to make the user be able to move the UILabel up and down across the view by attaching a UIPanGestureRecognizer to the UILabel and subsequently altering the constant of a constraint from the UILabel to the top of its view. So basically if the gesture recognizer detects them moving down 12pts, move the constant of the constraint 12pts to move the UILabel.
However, I want them to be stopped from moving further when they hit a certain vertical point (both too high or too low). I could just check the translation of the pan gesture, but my UILabel can be any number of lines, so if it's five lines instead of one, obviously it can't be panned down quite as far, so I can't rely on the translation of the pan gesture, I have to take into account the size of the label.
So I started monitoring its frame, and it works well, but in my implementation there's an annoying result where if they pan completely to the bottom limit, they have to pan really far back up before the UILabel "catches up" and comes with it (though no such problem exists when they hit the top boundary). Basically, they pan down to the bottom limit, and when they pan back up (this is all in the same gesture) it "sticks" momentarily until they pan far enough up, then it jumps up with their finger.
Here's the code I'm using to accomplish this:
- (void)textLabelPanned:(UIPanGestureRecognizer *)panGestureRecognizer {
if (panGestureRecognizer.state == UIGestureRecognizerStateBegan) {
_textDistanceFromTopBeforeMove = self.textToReadLabelPositionFromTopConstraint.constant;
}
else if (panGestureRecognizer.state == UIGestureRecognizerStateEnded) {
NSNumber *textDistanceFromTop = #(self.textToReadLabelPositionFromTopConstraint.constant);
[[NSUserDefaults standardUserDefaults] setObject:textDistanceFromTop forKey:#"TextDistanceFromTop"];
}
else {
if (CGRectGetMinY(self.textToReadLabel.frame) >= [UIScreen mainScreen].bounds.origin.y + CLOSEST_TEXT_DISTANCE_TO_TOP && CGRectGetMaxY(self.textToReadLabel.frame) <= [UIScreen mainScreen].bounds.size.height - CLOSEST_TEXT_DISTANCE_TO_BOTTOM) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
else if ([panGestureRecognizer translationInView:self.mainView].y > 0) {
if (CGRectGetMaxY(self.textToReadLabel.frame) + _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y < [UIScreen mainScreen].bounds.size.height - CLOSEST_TEXT_DISTANCE_TO_BOTTOM) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
}
else if ([panGestureRecognizer translationInView:self.mainView].y < 0) {
if (CGRectGetMinY(self.textToReadLabel.frame) + _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y > [UIScreen mainScreen].bounds.origin.y + CLOSEST_TEXT_DISTANCE_TO_TOP) {
self.textToReadLabelPositionFromTopConstraint.constant = _textDistanceFromTopBeforeMove + [panGestureRecognizer translationInView:self.mainView].y;
}
}
// If one of the options views are present and the user pans really low, hide the options as to allow the user to see where they're panning
if (_inSpeedChangingMode) {
if (CGRectGetMaxY(self.textToReadLabel.frame) > CGRectGetMinY(self.progressBar.frame) - 10) {
[self showWordOptions:nil];
}
}
else if (_inTextChangingMode) {
if (CGRectGetMaxY(self.textToReadLabel.frame) > CGRectGetMinY(self.progressBar.frame) - 10) {
[self showTextOptions:nil];
}
}
}
}
What exactly am I doing wrong that would be causing it to "stick"? And is there perhaps a better way to be doing this?
You can accomplish this entirely with constraints, defined either in Interface Builder, or in code. The trick is to define constraints that prevent the label from moving out of bounds that have higher priority than the constraints that set the desired position.
In my test project I set up a view hierarchy entirely in a storyboard having a 1) view controller view 2) "container view" which defines the bounds 3) multi-line UILabel. There are 6 constraints acting on the label from its container:
4 'space to' constraints (leading, trailing, top, bottom) prevent the label from ever being positioned outside the bounds of its parent container. The priority on these is set to the default '1000' value in Interface Builder. The relation for these constraints is '>=', and the constant value is '0'.
2 'space to' constraints (leading, top) drive the label's actual position. The priority on these is set lower; I chose '500'. These constraints have outlets in the view controller so they can be adjusted in code. The relation for these constraints is '=', and the initial value is whatever you want to position the label.
The label itself has a width constraint to force it to display with multiple lines.
Here's what this looks like in IB:
The selected constraint has a lower priority and is used to drive the x position of the label. This constraint is tied to an ivar in the view controller so it can be adjusted at runtime.
The selected constraint has a higher priority and is used to corral the label within its parent view.
And here is the code in the view controller:
#interface TSViewController ()
#end
#implementation TSViewController
{
IBOutlet NSLayoutConstraint* _xLayoutConstraint;
IBOutlet NSLayoutConstraint* _yLayoutConstraint;
}
- (IBAction) pan: (UIGestureRecognizer*) pgr
{
CGPoint p = [pgr locationInView: self.view];
p.x -= pgr.view.frame.size.width / 2.0;
p.y -= pgr.view.frame.size.height / 2.0;
_xLayoutConstraint.constant = p.x;
_yLayoutConstraint.constant = p.y;
}
#end
The UIPanGestureRecognizer is associated with the UILabel and has its callback set to the pan: method in the view controller.
If your app has minimum SDK iOS7, you can use UIKit Dynamics instead of those UIGestureRecognizers. Your problem could be easily solved with a UICollisionBehavoir combined with an UIAttachmentBehavior
You might want to look into it. Here's the apple sample project on UIKit Dynamics:
https://developer.apple.com/library/IOS/samplecode/DynamicsCatalog/Introduction/Intro.html
Play around with it and you'll be amazed what you can do with so little code.
WWDC 2013 sessions:
- Getting Started with UIKit Dynamics
- Advanced Techniques with UIKit Dynamics