Self-sizing cells and dynamic size controls for iOS - ios

Problem definition
I am trying to build a custom control which will behave similarly to UILabel. I should be able to place such a control inside of a self-sizing table cell and it should:
Wrap it's content (like UILabel with numberOfLines=0 does)
automatically extend self-sized cell height size
handle a device rotation
don't require any special code in UITableCellView or ViewControll to implement this functionality (UILabel doesn't require any special code for that).
Research
The first thing which I did is very simple. I decided to observe how UILabel works. I did following:
created a table with self-sizing cells
created a custom cell, put UILabel (with numberOfLines=0) in it
created constraints to make sure that UILabel occupies a whole cell
subclasses UILabel and overrode a bunch of methods to see how it behaves
I checked following things
Run it in a portrait (the label is displayed correctly over several lines) and the cell height is correct
Rotate it. The table width and height was updated and they are correct too.
I observed that it behaves well. It doesn't require any special code and I saw the order of (some) calls which system does to render it.
A partial solution
#Wingzero wrote a partial solution below. It creates cells of a correct size.
However, his solution has two problems:
It uses "self.superview.bounds.size.width". This could be used if your control occupies a whole cell. However, if you have anything else in the cell which uses constraints then such code won't calculate a width correctly.
It doesn't handle rotation at all. I am pretty sure it doesn't handle other resizing events (there are bunch of less common resizing events - like a statusbar getting bigger on a phone call etc).
Do you know how to solve these problems for this case?
I found a bunch of articles which talks about building more static custom controls and using pre-built controls in self-sizing cells.
However, I haven't found anything which put together a solution to handle both of these.

I have to use the answer section to post my ideas and moving forward, though it may not be your answer, since I am not fully understand what's blocking you, because I think you already know the intrinsic size and that's it.
based on the comments, I tried to create a view with a text property and override the intrinsic:
header file, later I found maxPreferredWidth is not used totally, so ignore it:
#interface LabelView : UIView
IB_DESIGNABLE
#property (nonatomic, copy) IBInspectable NSString *text;
#property (nonatomic, assign) IBInspectable CGFloat maxPreferredWidth;
#end
.m file:
#import "LabelView.h"
#implementation LabelView
-(void)setText:(NSString *)text {
if (![_text isEqualToString:text]) {
_text = text;
[self invalidateIntrinsicContentSize];
}
}
-(CGSize)intrinsicContentSize {
CGRect boundingRect = [self.text boundingRectWithSize:CGSizeMake(self.superview.bounds.size.width, CGFLOAT_MAX)
options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading
attributes:#{NSFontAttributeName:[UIFont systemFontOfSize:16]}
context:nil];
return boundingRect.size;
}
#end
and a UITableViewCell with xib:
header file:
#interface LabelCell : UITableViewCell
#property (weak, nonatomic) IBOutlet LabelView *labelView;
#end
.m file:
#implementation LabelCell
- (void)awakeFromNib {
[super awakeFromNib];
}
#end
xib, it's simple, just top, bottom, leading, trailing constraints:
So running it, based on the text's bounding rect, the cell's height is different, in my case, I have two text to loop: 1. "haha", 2. "asdf"{repeat many times to create a long string}
so the odd cell is 19 height and even cell is 58 height:
Is this what are you looking for?
My ideas:
the UITableView's cell's width is always the same as the table view, so that's the width. UICollectionView may be more issues there, but the point is we will calculate it and just return it is enough.
Demo project: https://github.com/liuxuan30/StackOverflow-DynamicSize
(I changed based on my old project, which has some images, ignore those.)

Here's a solution that meets your requirements and is also IBDesignable so it previews live in Interface Builder. This class will lay out a series of squares (the total number is equal to the IBInspectable count property). By default, it will just lay them all out in one long line. But if you set the wrap IBInspectable property to On, it will wrap the squares and increase its height based on its constrained width (like a UILabel with numberOfLines == 0). In a self-sizing table view cell, this will have the effect of pushing out the top and bottom to accommodate the wrapped intrinsic size of the custom view.
The code:
import Foundation
import UIKit
#IBDesignable class WrappingView : UIView {
private class InnerWrappingView : UIView {
private var lastPoint:CGPoint = CGPointZero
private var wrap = false
private var count:Int = 100
private var size:Int = 8
private var spacing:Int = 3
private func calculatedSize() -> CGSize {
lastPoint = CGPoint(x:-(size + spacing), y: 0)
for _ in 0..<count {
var nextPoint:CGPoint!
if wrap {
nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
} else {
nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
}
lastPoint = nextPoint
}
return CGSize(width: wrap ? bounds.width : lastPoint.x + CGFloat(size), height: lastPoint.y + CGFloat(size))
}
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.size != calculatedSize() || subviews.count == 0 else {
return
}
for subview in subviews {
subview.removeFromSuperview()
}
lastPoint = CGPoint(x:-(size + spacing), y: 0)
for _ in 0..<count {
let square = createSquareView()
var nextPoint:CGPoint!
if wrap {
nextPoint = lastPoint.x + CGFloat(size + spacing + size) <= bounds.width ? CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y) : CGPoint(x: 0, y: lastPoint.y + CGFloat(size + spacing))
} else {
nextPoint = CGPoint(x: lastPoint.x + CGFloat(size + spacing), y: lastPoint.y)
}
square.frame = CGRect(origin: nextPoint, size: square.bounds.size)
addSubview(square)
lastPoint = nextPoint
}
let newframe = CGRect(origin: frame.origin, size: calculatedSize())
frame = newframe
invalidateIntrinsicContentSize()
setNeedsLayout()
}
private func createSquareView() -> UIView {
let square = UIView(frame: CGRect(x: 0, y: 0, width: size, height: size))
square.backgroundColor = UIColor.blueColor()
return square
}
override func intrinsicContentSize() -> CGSize {
return calculatedSize()
}
}
#IBInspectable var count:Int = 500 {
didSet {
innerView.count = count
layoutSubviews()
}
}
#IBInspectable var size:Int = 8 {
didSet {
innerView.size = size
layoutSubviews()
}
}
#IBInspectable var spacing:Int = 3 {
didSet {
innerView.spacing = spacing
layoutSubviews()
}
}
#IBInspectable var wrap:Bool = false {
didSet {
innerView.wrap = wrap
layoutSubviews()
}
}
private var _innerView:InnerWrappingView! {
didSet {
clipsToBounds = true
addSubview(_innerView)
_innerView.clipsToBounds = true
_innerView.frame = bounds
_innerView.wrap = wrap
_innerView.translatesAutoresizingMaskIntoConstraints = false
_innerView.leftAnchor.constraintEqualToAnchor(leftAnchor).active = true
_innerView.rightAnchor.constraintEqualToAnchor(rightAnchor).active = true
_innerView.topAnchor.constraintEqualToAnchor(topAnchor).active = true
_innerView.bottomAnchor.constraintEqualToAnchor(bottomAnchor).active = true
_innerView.setContentCompressionResistancePriority(750, forAxis: .Vertical)
_innerView.setContentHuggingPriority(251, forAxis: .Vertical)
}
}
private var innerView:InnerWrappingView! {
if _innerView == nil {
_innerView = InnerWrappingView()
}
return _innerView
}
override func layoutSubviews() {
super.layoutSubviews()
if innerView.bounds.width != bounds.width {
innerView.frame = CGRect(origin: CGPointZero, size: CGSize(width: bounds.width, height: 0))
}
innerView.layoutSubviews()
if innerView.bounds.height != bounds.height {
invalidateIntrinsicContentSize()
superview?.layoutIfNeeded()
}
}
override func intrinsicContentSize() -> CGSize {
return innerView.calculatedSize()
}
}
In my sample application, I set the table view to dequeue a cell containing this custom view for each row, and set the count property of the custom view to 20 * the indexPath's row. The custom view is constrained to 50% of the cell's width, so its width will change automatically when moving between landscape and portrait. Because each successive table cell wraps a longer and longer string of squares, each cell is automatically sized to be taller and taller.
When running, it looks like this (includes demonstration of rotation):

To build on the other answer from #Wingzero, layout is a complex problem...
The maxPreferredWidth mentioned is important, and relates to preferredMaxLayoutWidth of UILabel. The point of this attribute is to tell a label not to just be one long line and to instead prefer to wrap if the width gets to that value. So when calculating the intrinsic size you would use the minimum of the preferredMaxLayoutWidth (if set) or the view width as the max width.
Another key aspect is invalidateIntrinsicContentSize which the view should call on itself whenever something changes and means a new layout is required.
UILabel doesn't handle rotation - it doesn't know about it. It's the responsibility of the view controller to detect and handle rotation, generally by invalidating the layout and updating the view size before triggering a new layout run. The labels (and other views) are just there to handle the resulting layout. As part of the rotation you (i.e. a view controller) may change the preferredMaxLayoutWidth as it makes sense to allow more width in landscape layout for example.

Are you looking for something like this ^^? The cell has dynamic heights to facilitate the content of the UILabel, and there's no code to calculate size/width/height whatsoever - just some constraints.
Essentially, the label at left-hand side has top, bottom and leading margin to the cell, and trailing margin to the right-hand side label, which has trailing margin to the cell. Just need one label? Ignore the right hand side label then, and configure the left hand side label with a trailing constraint to the cell.
And if you need the label to be multi-line, configure that. Set the numberOfLines to 2, 3, or 0, up to you.
You don't need to calculate table view cell's height, the auto layout will calculate for you; but you need to let it know that, by telling it to use auto dimension: self.tableView.rowHeight = UITableViewAutomaticDimension, or return it in tableView:heightForRowAtIndexPath:. And you can also tell table view a "rough" estimation in tableView:estimatedHeightForRowAtIndexPath: for a better performance.
Still not working? Set the Content Compression Resistance Priority - Vertical to 1000 / Required for the UILabel in question, so that the label's content will try its best to "resist the compression", and the numberOfLines configuration will be fully acknowledged.
And it rotates? Try to observe the rotation (there're orientation change notifications) and then update layout (setNeedsLayout).
Still not working? More reads here: Using Auto Layout in UITableView for dynamic cell layouts & variable row heights

Related

Magnify UITableViewCell as it scrolls closer to center of screen

I've been working at this for a good 48 hours now and can't seem to solve it on my own. What I'm trying to achieve is when a UITableViewCell is scrolled from the bottom to the top of the screen it starts small, magnifies as it reaches the center portion of the screen, and then begins to reduce in size again as it scrolls up and off of the screen (and vice versa, top to bottom). I can kind of get this to work, but it seems as though the contentView is resizing, the actual cell height (of the UITableView) is not.
What I want:
Scrolling up or down smoothly adjusts the size of all the uitableviewcells based on its y position on the screen.
What appears to be happening (though I'm having trouble confirming):
Again, it's as if the cell height is not being adjusted, but the height of the contentView is.
I can achieve this by overriding the layoutVisibleCells method of the UITableView:
func layoutVisibleCells() {
let indexpaths = indexPathsForVisibleRows!
let totalVisibleCells = indexpaths.count - 1
if totalVisibleCells <= 0 { return }
for index in 0...totalVisibleCells {
let indexPath = indexpaths[index]
if let cell = cellForRowAtIndexPath(indexPath) {
var frame = cell.frame
if let superView = superview {
let point = convertPoint(frame.origin, toView:superView)
let pointScale = point.y / CGFloat(superView.bounds.size.height)
var height = frame.size.height + point.x;
if height < 150.0 {
height = 150.0;
}
if height > 200.0 {
height = 200.0;
}
frame.size.height = height;
NSLog("index %li: x: %f", index,frame.origin.x);
NSLog("point at index %li: %f, %f - percentage: %f", index,point.x,point.y, pointScale);
}
cell.frame = frame
}
}
}
I took the idea for making the adjustment in this method from COBezierTableView, which is written in Swift. The rest of my project is in Obj-C, so deciphering things was a bit challenging, as I'm still learning my way there.
Any insight on my current attempt, or suggestions for how to achieve this in another, completely different way, are totally welcome. Thanks in advance.
UPDATE
So I've actually found that the opposite is happening of what I suspected. The UITableViewCell frame IS being resized correctly. It's the cell's contentView that is not resizing fully. It does resize partially, but for some reason it does not resize to match the cell's frame. Not sure why ...

Scroll to the bottom of textView programmatically

I made a button that adds some text to a textView and I want it to automatically scroll to the bottom as it is pressed so that user would be able to see the new text added.
I can't use this solution in Swift because I don't know Objective-C.
Does anyone know how can I scroll to the bottom of a textView in Swift? Thanks.
Tried both content offset and scrolltoview solutions, get mixed results and choppy scrolling; having looked around the below seemed to work and produce consistent scrolling to the bottom when needed.
In viewdidload:
self.storyTextView.layoutManager.allowsNonContiguousLayout = false
Then when needed:
let stringLength:Int = self.storyTextView.text.characters.count
self.storyTextView.scrollRangeToVisible(NSMakeRange(stringLength-1, 0))
Swift 4
let bottom = NSMakeRange(textLog.text.count - 1, 1)
textLog.scrollRangeToVisible(bottom)
Swift 3
let bottom = NSMakeRange(textLog.text.characters.count - 1, 1)
textLog.scrollRangeToVisible(bottom)
Update: thanks #AntoineRucquoy for Swift 4 reminder!
Simply, where myTextView is the UITextView in question:
let bottom = myTextView.contentSize.height
myTextView.setContentOffset(CGPoint(x: 0, y: bottom), animated: true) // Scrolls to end
So if you click the link you posted the accepted answer shows this objective-C code:
-(void)scrollTextViewToBottom:(UITextView *)textView
{
if(textView.text.length > 0 )
{
NSRange bottom = NSMakeRange(textView.text.length -1, 1);
[textView scrollRangeToVisible:bottom];
}
}
So your challenge is to convert that code to Swift.
Break it into pieces and tackle them one at a time. First, the method definition itself.
The method is called scrollTextViewToBottom. It takes a UITextView as a parameter, and does not return a result. How would you write that method definition in Swift?
Next look that the body of the method. The if statement should be exactly the same in Swift.
The creation of an NSRange is all but identical. You just need to change it a little bit:
let bottom = NSMakeRange(textView.text.length -1, 1)
The part that's probably the hardest for somebody who doesn't know Objective-C is the method call. It's sending the message scrollRangeToVisible to the object textView. The parameter passed is bottom. See if you can rewrite that line in Swift. Then put the whole thing together.
I use the following in an app that scrolls to the bottom automatically when text is added:
First when initializing your textView, do the following:
textView.layoutManager.allowsNonContiguousLayout = false
textView.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil)
Then add the following observer method:
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
var bottom = textView.contentSize.height - textView.frame.size.height
if bottom < 0 {
bottom = 0
}
if textView.contentOffset.y != bottom {
textView.setContentOffset(CGPoint(x: 0, y: bottom), animated: true)
}
}
setting allowsNonContiguousLayout to false fixed contentSize problems for me.
Adding the contentSize observer will observe for any new changes in the contentSize of the textView and call the -observeValue(forKeyPath...) function when changes are made.
In the -observeValue(...) function, we first get the bottom (y contentOffset when fully scrolled to the bottom). We then check if that value is negative, meaning that the contentSize height is smaller than the textView frame height and you can't really do any scrolling. If you try to programmatically scroll with that negative value, it will cause that infamous jitter that many people know and love. So to avoid this jitter we simply set the value to what it already should be, 0 or you can also just return.
Then we just test to see if the contentOffset doesn't already equal the bottom value, we give it that new value. This avoids setting the contentOffset when it doesn't need to be set.
Language:Swift
Follow steps as below:
//Declare
#IBOutlet weak var trTextDataRead: UITextView!
//Cunstom method
func insertTextView(text: String){
//Insert text
trTextDataRead.text.append(text)
//Scroll to the end
let btm = NSMakeRange(trTextDataRead.text.lengthOfBytes(using: String.Encoding.utf8), 0)
trTextDataRead.scrollRangeToVisible(btm)
}
If you're dealing with the UITextView's attributedText property:
in viewDidLoad()
self.storyTextView.layoutManager.allowsNonContiguousLayout = false
in your scrolling method
let stringLength:Int = self.storyTextView.attributedText.string.characters.count
self.storyTextView.scrollRangeToVisible(NSMakeRange(stringLength-1, 0))
Swift 4
private func textViewScrollToBottom() {
let bottomRange = NSMakeRange(self.myTextView.text.count - 1, 1)
self.myTextView.scrollRangeToVisible(bottomRange)
}
UITextView has a property contentOffsent. You can either set textView.contentOffset or textView.setContentOffset(offset, animated: true)
For example if the contentSize of your text view is (100, 500) but the height of the text view is only 100, then to scroll to the bottom, set the contentOffset property to (0, 400) (this is for a vertical text view). More generically the formula for scrolling to the bottom is textView.contentSize.height-textView.height. Every time your button is pressed, set the offset.
I would also really recommend reading the documentation and trying to figure it out. Swift and iOS is quite well documented and a question like this is easily searchable via Google.
Edit: This works because UITextView inherits from UIScrollView.
Sidenote: I wrote a UITextView subclass where you can set the vertical text alignment so if you set the text alignment to .Bottom, the text will align with the bottom of the view.
class TextView: UITextView {
enum VerticalAlignment: Int {
case Top = 0, Middle, Bottom
}
var verticalAlignment: VerticalAlignment = .Middle
//override contentSize property and observe using didSet
override var contentSize: CGSize {
didSet {
let textView = self
let height = textView.bounds.size.height
let contentHeight:CGFloat = contentSize.height
var topCorrect: CGFloat = 0.0
switch(self.verticalAlignment){
case .Top:
textView.contentOffset = CGPointZero //set content offset to top
case .Middle:
topCorrect = (height - contentHeight * textView.zoomScale)/2.0
topCorrect = topCorrect < 0 ? 0 : topCorrect
textView.contentOffset = CGPoint(x: 0, y: -topCorrect)
case .Bottom:
topCorrect = textView.bounds.size.height - contentHeight
topCorrect = topCorrect < 0 ? 0 : topCorrect
textView.contentOffset = CGPoint(x: 0, y: -topCorrect)
}
if contentHeight >= height { //if the contentSize is greater than the height
topCorrect = contentHeight - height //set the contentOffset to be the
topCorrect = topCorrect < 0 ? 0 : topCorrect //contentHeight - height of textView
textView.contentOffset = CGPoint(x: 0, y: topCorrect)
}
}
}
// MARK: - UIView
override func layoutSubviews() {
super.layoutSubviews()
let size = self.contentSize //forces didSet to be called
self.contentSize = size
}
}
In the above example (pulled directly from my subclass), you'll notice I make extensive use of the contentOffset property. I do some calculations to figure out where the offset should be based on the vertical alignment property and then set the content offset property according (which is how you programmatically scroll with a scroll view)
A lot of people are explaining how to scroll to the bottom, but one thing to note is that this won't work if you place it in viewDidLoad. For example: I needed to use this to scroll a log to the bottom when the page loaded. In order to do this, I had to implement the following code
- (void) viewDidLoad {
[super viewDidLoad];
[_logTextView setText:[Logger loadLogText]];
}
- (void) viewDidLayoutSubviews {
[_logTextView
setContentOffset:CGPointMake(
0.0,
_logTextView.contentSize.height
- _logTextView.frame.size.height
)
animated:NO
];
}
The actual scrolling of the UITextView cannot be done in viewDidLoad.
In my viewDidLoad implementation I set the text for the text box.
In my viewDidLayoutSubviews implementation I set the content offset for the UITextView by generating a CGPoint using the height of the text views content minus the height of the text view itself. This way, when it scrolls to the bottom, the bottom of the text is not at the top of the box and instead, the bottom of the text is at the bottom of the box.
Swift 5 - with extension to UITextView + avoid slow scroll in large texts (this issue killed my main Thread)
extension UITextView {
func scrollToBottom() {
// IMPORTANT - only use (text as NSString) to get the length, since text.length is O(N) and it will kill the main thread.
let length = (text as NSString).length
if length > 1 {
scrollRangeToVisible(NSMakeRange(length - 1, 1))
}
}
}

How do I make a UIImage fade in and fade out as I scroll?

I have a stretchy header that I made following this tutorial http://blog.matthewcheok.com/design-teardown-stretchy-headers/. Anyway it's working perfectly but I'm having trouble making a UIView on top of it fade out as the view stretched and returning to original alpha as the view is returned to normal. Best I could come up with is this:
override func
scrollViewDidScroll(scrollView: UIScrollView) {
updateHeaderView()
var offset = scrollView.contentOffset.y
if offset < -170 {
headerBlurImageView?.alpha = max(0.0, offset - 95/35)
} else {
self.headerBlurImageView?.alpha = 1
}
}
But it barely works. There is no smooth transition between the alphas and when the view is returned to normal the alpha doesn't return. Any advice or hints?
Update: I managed to do the exact opposite of what I wanted :p Here's the code:
override func scrollViewDidScroll(scrollView: UIScrollView) {
updateHeaderView()
var height: CGFloat
var position: CGFloat
var percent: CGFloat
height = scrollView.bounds.size.height/2
position = max(-scrollView.contentOffset.y, 0.0)
percent = min(position / height, 1.0)
self.headerBlurImageView.alpha = percent
}
Take a look at the UIScrollViewDelegate protocol. there are a number of messages that relate to starting and ending dragging, and decelerating. You should be able to set your view controller up as the scroll view's delegate and implement some of those methods. I'd create a UIView animation that animates the header's alpha down when scrolling begins, and another UIView animation that animates it back to opaque once scrolling ends (or possibly once deceleration ends.)
Layout two UIImageViews on top of one another (in this example, bg is under bgB), then layout the UIScrollview at the very top. In the scrollviewDidScroll method, place the following code:
float off = cv.contentOffset.x;
float floatedIndex = off/cv.frame.size.width;
int left = floatedIndex;
int right = ceil(floatedIndex);
if (right>events.count-1){ right=events.count-1; }
float alpha = floatedIndex-left;
UIImage * leftImage = nil;
UIImage * rightImage = nil;
NSDictionary * ld = events[left];
NSDictionary * rd = events[right];
NSObject * o = ld[#"artwork"];
if ([o isKindOfClass:[UIImage class]]){
leftImage = ld[#"artwork"];
}
o = rd[#"artwork"];
if ([o isKindOfClass:[UIImage class]]){
rightImage = rd[#"artwork"];
}
bg.image = leftImage;
bgB.image = rightImage;
bgB.alpha = alpha;
The 'left' and 'right' ints correspond to indices in an array. There's a line checking that the scrollview cannot accidently go 'too far right' ie trying to find an index outside the bounds of the array.
Then I'm pulling data from an array called events, and checking for the existence of an image in the resulting dictionary (but you could use it for a straight up array of images), updating both UIImageviews and then fading the one on top (bgB) in and out depending on the scroll's content offset.
The 'animation' correlates with user-interaction, which is great, but sometimes it's better to use animation blocks with another scrollview delegate method.

Emulating aspect-fit behaviour using AutoLayout constraints in Xcode 6

I want to use AutoLayout to size and layout a view in a manner that is reminiscent of UIImageView's aspect-fit content mode.
I have a subview inside a container view in Interface Builder. The subview has some inherent aspect ratio which I wish to respect. The container view's size is unknown until runtime.
If the container view's aspect ratio is wider than the subview, then I want the subview's height to equal the parent view's height.
If the container view's aspect ratio is taller than the subview, then I want the subview's width to equal the parent view's width.
In either case I wish the subview to be centered horizontally and vertically within the container view.
Is there a way to achieve this using AutoLayout constraints in Xcode 6 or in previous version? Ideally using Interface Builder, but if not perhaps it is possible to define such constraints programmatically.
You're not describing scale-to-fit; you're describing aspect-fit. (I have edited your question in this regard.) The subview becomes as large as possible while maintaining its aspect ratio and fitting entirely inside its parent.
Anyway, you can do this with auto layout. You can do it entirely in IB as of Xcode 5.1. Let's start with some views:
The light green view has an aspect ratio of 4:1. The dark green view has an aspect ratio of 1:4. I'm going to set up constraints so that the blue view fills the top half of the screen, the pink view fills the bottom half of the screen, and each green view expands as much as possible while maintaining its aspect ratio and fitting in its container.
First, I'll create constraints on all four sides of the blue view. I'll pin it to its nearest neighbor on each edge, with a distance of 0. I make sure to turn off margins:
Note that I don't update the frame yet. I find it easier to leave room between the views when setting up constraints, and just set the constants to 0 (or whatever) by hand.
Next, I pin the left, bottom, and right edges of the pink view to its nearest neighbor. I don't need to set up a top edge constraint because its top edge is already constrained to the bottom edge of the blue view.
I also need an equal-heights constraint between the pink and blue views. This will make them each fill half the screen:
If I tell Xcode to update all the frames now, I get this:
So the constraints I've set up so far are correct. I undo that and start work on the light green view.
Aspect-fitting the light green view requires five constraints:
A required-priority aspect ratio constraint on the light green view. You can create this constraint in a xib or storyboard with Xcode 5.1 or later.
A required-priority constraint limiting the width of the light green view to be less than or equal to the width of its container.
A high-priority constraint setting the width of the light green view to be equal to the width of its container.
A required-priority constraint limiting the height of the light green view to be less than or equal to the height of its container.
A high-priority constraint setting the height of the light green view to be equal to the height of its container.
Let's consider the two width constraints. The less-than-or-equal constraint, by itself, is not sufficient to determine the width of the light green view; many widths will fit the constraint. Since there's ambiguity, autolayout will try to choose a solution that minimizes the error in the other (high-priority but not required) constraint. Minimizing the error means making the width as close as possible to the container's width, while not violating the required less-than-or-equal constraint.
The same thing happens with the height constraint. And since the aspect-ratio constraint is also required, it can only maximize the size of the subview along one axis (unless the container happens to have the same aspect ratio as the subview).
So first I create the aspect ratio constraint:
Then I create equal width and height constraints with the container:
I need to edit these constraints to be less-than-or-equal constraints:
Next I need to create another set of equal width and height constraints with the container:
And I need to make these new constraints less than required priority:
Finally, you asked for the subview to be centered in its container, so I'll set up those constraints:
Now, to test, I'll select the view controller and ask Xcode to update all the frames. This is what I get:
Oops! The subview has expanded to completely fill its container. If I select it, I can see that in fact it's maintained its aspect ratio, but it's doing an aspect-fill instead of an aspect-fit.
The problem is that on a less-than-or-equal constraint, it matters which view is at each end of the constraint, and Xcode has set up the constraint opposite from my expectation. I could select each of the two constraints and reverse its first and second items. Instead, I'll just select the subview and change the constraints to be greater-than-or-equal:
Xcode updates the layout:
Now I do all the same things to the dark green view on the bottom. I need to make sure its aspect ratio is 1:4 (Xcode resized it in a weird way since it didn't have constraints). I won't show the steps again since they're the same. Here's the result:
Now I can run it in the iPhone 4S simulator, which has a different screen size than IB used, and test rotation:
And I can test in in the iPhone 6 simulator:
I've uploaded my final storyboard to this gist for your convenience.
Rob, your answer is awesome!
I also know that this question is specifically about achieving this by using auto-layout. However, just as a reference, I'd like to show how this can be done in code. You set up the top and bottom views (blue and pink) just like Rob showed. Then you create a custom AspectFitView:
AspectFitView.h:
#import <UIKit/UIKit.h>
#interface AspectFitView : UIView
#property (nonatomic, strong) UIView *childView;
#end
AspectFitView.m:
#import "AspectFitView.h"
#implementation AspectFitView
- (void)setChildView:(UIView *)childView
{
if (_childView) {
[_childView removeFromSuperview];
}
_childView = childView;
[self addSubview:childView];
[self setNeedsLayout];
}
- (void)layoutSubviews
{
[super layoutSubviews];
if (_childView) {
CGSize childSize = _childView.frame.size;
CGSize parentSize = self.frame.size;
CGFloat aspectRatioForHeight = childSize.width / childSize.height;
CGFloat aspectRatioForWidth = childSize.height / childSize.width;
if ((parentSize.height * aspectRatioForHeight) > parentSize.height) {
// whole height, adjust width
CGFloat width = parentSize.width * aspectRatioForWidth;
_childView.frame = CGRectMake((parentSize.width - width) / 2.0, 0, width, parentSize.height);
} else {
// whole width, adjust height
CGFloat height = parentSize.height * aspectRatioForHeight;
_childView.frame = CGRectMake(0, (parentSize.height - height) / 2.0, parentSize.width, height);
}
}
}
#end
Next, you change the class of the blue and pink views in the storyboard to be AspectFitViews. Finally you set two outlets to your viewcontroller topAspectFitView and bottomAspectFitView and set their childViews in viewDidLoad:
- (void)viewDidLoad {
[super viewDidLoad];
UIView *top = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 500, 100)];
top.backgroundColor = [UIColor lightGrayColor];
UIView *bottom = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 100, 500)];
bottom.backgroundColor = [UIColor greenColor];
_topAspectFitView.childView = top;
_bottomAspectFitView.childView = bottom;
}
So it's not hard to do this in code and it is still very adaptable and works with variably-sized views and different aspect ratios.
Update July 2015: Find a demo app here: https://github.com/jfahrenkrug/SPWKAspectFitView
This is for macOS.
I have problem to use Rob's way to achieve aspect fit on the OS X application. But I made it with another way -- Instead of using width and height, I used leading, trailing, top and bottom space.
Basically, add two leading spaces where one is >= 0 #1000 required priority and another one is = 0 #250 low priority. Do same settings to trailing, top and bottom space.
Of course, you need to set aspect ratio and centre X and centre Y.
And then job's done!
I needed a solution from the accepted answer, but executed from the code. The most elegant way I've found is using Masonry framework.
#import "Masonry.h"
...
[view mas_makeConstraints:^(MASConstraintMaker *make) {
make.width.equalTo(view.mas_height).multipliedBy(aspectRatio);
make.size.lessThanOrEqualTo(superview);
make.size.equalTo(superview).with.priorityHigh();
make.center.equalTo(superview);
}];
I found myself wanting aspect-fill behavior so that a UIImageView would maintain its own aspect ratio and entirely fill the container view. Confusingly, my UIImageView was breaking BOTH high-priority equal-width and equal-height constraints (described in Rob's answer) and rendering at full resolution.
The solution was simply to set the UIImageView's Content Compression Resistance Priority lower than the priority of the equal-width and equal-height constraints:
This is a port of #rob_mayoff's excellent answer to a code-centric approach, using NSLayoutAnchor objects and ported to Xamarin. For me, NSLayoutAnchor and related classes have made AutoLayout much easier to program:
public class ContentView : UIView
{
public ContentView (UIColor fillColor)
{
BackgroundColor = fillColor;
}
}
public class MyController : UIViewController
{
public override void ViewDidLoad ()
{
base.ViewDidLoad ();
//Starting point:
var view = new ContentView (UIColor.White);
blueView = new ContentView (UIColor.FromRGB (166, 200, 255));
view.AddSubview (blueView);
lightGreenView = new ContentView (UIColor.FromRGB (200, 255, 220));
lightGreenView.Frame = new CGRect (20, 40, 200, 60);
view.AddSubview (lightGreenView);
pinkView = new ContentView (UIColor.FromRGB (255, 204, 240));
view.AddSubview (pinkView);
greenView = new ContentView (UIColor.Green);
greenView.Frame = new CGRect (80, 20, 40, 200);
pinkView.AddSubview (greenView);
//Now start doing in code the things that #rob_mayoff did in IB
//Make the blue view size up to its parent, but half the height
blueView.TranslatesAutoresizingMaskIntoConstraints = false;
var blueConstraints = new []
{
blueView.LeadingAnchor.ConstraintEqualTo(view.LayoutMarginsGuide.LeadingAnchor),
blueView.TrailingAnchor.ConstraintEqualTo(view.LayoutMarginsGuide.TrailingAnchor),
blueView.TopAnchor.ConstraintEqualTo(view.LayoutMarginsGuide.TopAnchor),
blueView.HeightAnchor.ConstraintEqualTo(view.LayoutMarginsGuide.HeightAnchor, (nfloat) 0.5)
};
NSLayoutConstraint.ActivateConstraints (blueConstraints);
//Make the pink view same size as blue view, and linked to bottom of blue view
pinkView.TranslatesAutoresizingMaskIntoConstraints = false;
var pinkConstraints = new []
{
pinkView.LeadingAnchor.ConstraintEqualTo(blueView.LeadingAnchor),
pinkView.TrailingAnchor.ConstraintEqualTo(blueView.TrailingAnchor),
pinkView.HeightAnchor.ConstraintEqualTo(blueView.HeightAnchor),
pinkView.TopAnchor.ConstraintEqualTo(blueView.BottomAnchor)
};
NSLayoutConstraint.ActivateConstraints (pinkConstraints);
//From here, address the aspect-fitting challenge:
lightGreenView.TranslatesAutoresizingMaskIntoConstraints = false;
//These are the must-fulfill constraints:
var lightGreenConstraints = new []
{
//Aspect ratio of 1 : 5
NSLayoutConstraint.Create(lightGreenView, NSLayoutAttribute.Height, NSLayoutRelation.Equal, lightGreenView, NSLayoutAttribute.Width, (nfloat) 0.20, 0),
//Cannot be larger than parent's width or height
lightGreenView.WidthAnchor.ConstraintLessThanOrEqualTo(blueView.WidthAnchor),
lightGreenView.HeightAnchor.ConstraintLessThanOrEqualTo(blueView.HeightAnchor),
//Center in parent
lightGreenView.CenterYAnchor.ConstraintEqualTo(blueView.CenterYAnchor),
lightGreenView.CenterXAnchor.ConstraintEqualTo(blueView.CenterXAnchor)
};
//Must-fulfill
foreach (var c in lightGreenConstraints)
{
c.Priority = 1000;
}
NSLayoutConstraint.ActivateConstraints (lightGreenConstraints);
//Low priority constraint to attempt to fill parent as much as possible (but lower priority than previous)
var lightGreenLowPriorityConstraints = new []
{
lightGreenView.WidthAnchor.ConstraintEqualTo(blueView.WidthAnchor),
lightGreenView.HeightAnchor.ConstraintEqualTo(blueView.HeightAnchor)
};
//Lower priority
foreach (var c in lightGreenLowPriorityConstraints)
{
c.Priority = 750;
}
NSLayoutConstraint.ActivateConstraints (lightGreenLowPriorityConstraints);
//Aspect-fit on the green view now
greenView.TranslatesAutoresizingMaskIntoConstraints = false;
var greenConstraints = new []
{
//Aspect ratio of 5:1
NSLayoutConstraint.Create(greenView, NSLayoutAttribute.Height, NSLayoutRelation.Equal, greenView, NSLayoutAttribute.Width, (nfloat) 5.0, 0),
//Cannot be larger than parent's width or height
greenView.WidthAnchor.ConstraintLessThanOrEqualTo(pinkView.WidthAnchor),
greenView.HeightAnchor.ConstraintLessThanOrEqualTo(pinkView.HeightAnchor),
//Center in parent
greenView.CenterXAnchor.ConstraintEqualTo(pinkView.CenterXAnchor),
greenView.CenterYAnchor.ConstraintEqualTo(pinkView.CenterYAnchor)
};
//Must fulfill
foreach (var c in greenConstraints)
{
c.Priority = 1000;
}
NSLayoutConstraint.ActivateConstraints (greenConstraints);
//Low priority constraint to attempt to fill parent as much as possible (but lower priority than previous)
var greenLowPriorityConstraints = new []
{
greenView.WidthAnchor.ConstraintEqualTo(pinkView.WidthAnchor),
greenView.HeightAnchor.ConstraintEqualTo(pinkView.HeightAnchor)
};
//Lower-priority than above
foreach (var c in greenLowPriorityConstraints)
{
c.Priority = 750;
}
NSLayoutConstraint.ActivateConstraints (greenLowPriorityConstraints);
this.View = view;
view.LayoutIfNeeded ();
}
}
Maybe this is the shortest answer, with Masonry, which also supports aspect-fill and stretch.
typedef NS_ENUM(NSInteger, ContentMode) {
ContentMode_aspectFit,
ContentMode_aspectFill,
ContentMode_stretch
}
// ....
[containerView addSubview:subview];
[subview mas_makeConstraints:^(MASConstraintMaker *make) {
if (contentMode == ContentMode_stretch) {
make.edges.equalTo(containerView);
}
else {
make.center.equalTo(containerView);
make.edges.equalTo(containerView).priorityHigh();
make.width.equalTo(content.mas_height).multipliedBy(4.0 / 3); // the aspect ratio
if (contentMode == ContentMode_aspectFit) {
make.width.height.lessThanOrEqualTo(containerView);
}
else { // contentMode == ContentMode_aspectFill
make.width.height.greaterThanOrEqualTo(containerView);
}
}
}];
I couldn't find any ready-to-go fully programatical solution, so here is my take in swift 5 for the aspect fill extension to a view:
extension UIView {
public enum FillingMode {
case full(padding:Int = 0)
case aspectFit(ratio:CGFloat)
// case aspectFill ...
}
public func addSubview(_ newView:UIView, withFillingMode fillingMode:FillingMode) {
newView.translatesAutoresizingMaskIntoConstraints = false
addSubview(newView)
switch fillingMode {
case let .full(padding):
let cgPadding = CGFloat(padding)
NSLayoutConstraint.activate([
newView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: cgPadding),
newView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -cgPadding),
newView.topAnchor.constraint(equalTo: topAnchor, constant: cgPadding),
newView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -cgPadding)
])
case let .aspectFit(ratio):
guard ratio != 0 else { return }
NSLayoutConstraint.activate([
newView.centerXAnchor.constraint(equalTo: centerXAnchor),
newView.centerYAnchor.constraint(equalTo: centerYAnchor),
newView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor),
newView.leadingAnchor.constraint(equalTo: leadingAnchor).usingPriority(900),
newView.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
newView.trailingAnchor.constraint(equalTo: trailingAnchor).usingPriority(900),
newView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor),
newView.topAnchor.constraint(equalTo: topAnchor).usingPriority(900),
newView.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor),
newView.bottomAnchor.constraint(equalTo: bottomAnchor).usingPriority(900),
newView.heightAnchor.constraint(equalTo: newView.widthAnchor, multiplier: CGFloat(ratio)),
])
}
}
}
And here is the priority extension (comes from another thread but I "secured it" with some pattern matching between 1...1000:
extension NSLayoutConstraint {
/// Returns the constraint sender with the passed priority.
///
/// - Parameter priority: The priority to be set.
/// - Returns: The sended constraint adjusted with the new priority.
func usingPriority(_ priority: Int) -> NSLayoutConstraint {
self.priority = UILayoutPriority( (1...1000 ~= priority) ? Float(priority) : 1000 )
return self
}
}
Hope it helps ~

Parallax effect with UIScrollView subviews

I'm trying to create a Parallax effect on a UIView inside a UIScrollView.
The effect seems to work, but not so well.
First i add two UIView sub-views to a UIScrollView and set the UIScrollViews contentSize.
The Views sum up and create a contentSize of {320, 1000}.
Then I implemented the following in scrollViewDidScroll:
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
CGFloat offsetY = scrollView.contentOffset.y;
CGFloat percentage = offsetY / scrollView.contentSize.height;
NSLog(#"percent = %f", percentage);
if (offsetY < 0) {
firstView.center = CGPointMake(firstView.center.x, firstView.center.y - percentage * 10);
} else if (offsetY > 0){
firstView.center = CGPointMake(firstView.center.x, firstView.center.y + percentage * 10);
}
}
These lines of code do create a parallax effect, but as the scrolling continues, the view does not return to it's original position if i scroll to the original starting position.
I have tried manipulating the views layers and frame, all with the same results.
Any Help will be much appreciated.
The problem you have is that you are basing your secondary scrolling on a ratio of offset to size, not just on the current offset. So when you increase from an offset of 99 to 100 (out of say 100) your secondary scroll increases by 10, but when you go back down to 99 your secondary scroll only decreases by 9.9, and is thereby no longer in the same spot as it was last time you were at 99. Non-linear scrolling is possible, but not the way you are doing it.
A possible easier way to deal with this is to create a second scrollview and place it below your actual scrollview. Make it non intractable (setUserInteractionEnabled:false) and modify it's contentOffset during the main scrolling delegate instead of trying to move a UIImageView manually.
- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[scrollView2 setContentOffset:CGPointMake(scrollView.contentOffset.x,scrollView.contentOffset.y * someScalingFactor) animated:NO];
}
But make sure not to set a delegate for the scrollView2, otherwise you may get a circular delegate method call that will not end well for you.
Scaling Factor being the key element...
...let me offer a 1:1 calculation:
Assuming 2 UIScrollView, one in the foreground and on in the rear, assuming the foreground controls the rear, and further assuming that a full width in the foreground corresponds to a full width in the background, you then need to apply the fore ratio, not the fore offset.
func scrollViewDidScroll(_ scrollView: UIScrollView) {
let foreSpan = foreScrolView.bounds.width - foreScrolView.contentSize.width
let foreRatio = scrollView.contentOffset.x / foreSpan
let rearSpan = rearScrollView.bounds.width - rearScrollView.contentSize.width
rearScrollView.setContentOffset(
CGPoint(x: foreRatio * rearSpan, y: 0),
animated: false)
}
Final effect
The two scrollers, fore and rear, each contain a UIImageView displayed at its full width:
let foreImg = UIImageView.init(image: UIImage(named: "fore"))
foreImg.frame = CGRect(x: 0, y: 0,
width: foreImg.frame.width,
height: foreScrolView.bounds.height)
foreScrolView.contentSize = foreImg.frame.size
foreScrolView.addSubview(foreImg)
let rearImg = UIImageView.init(image: UIImage(named: "rear"))
rearImg.frame = CGRect(x: 0, y: 0,
width: rearImg.frame.width,
height: rearScrollView.bounds.height)
rearScrollView.contentSize = rearImg.frame.size
rearScrollView.addSubview(rearImg)
This will scroll both images at a different speed, covering each image in full from edge to edge.
► Find this solution on GitHub and additional details on Swift Recipes.

Resources