How to programmatically lay out stacking views and rotate? - ios

I'd like to layout 3 imageViews programmatically by setting the frames manually. I am able to set up the middle image just fine but when trying to rotate the other two, the images's frames distort when using CGAffineTransform(rotatedBy:)
Right now I am setting the center image based on the center of the view and using that as a reference point for the left and right images. Any suggestions to rotate the images or if there's anything glaringly wrong with my approach? I suspect the issue has to do with the rotating point when using CGAffineTransform. I read around and folks say it's defaulted at (0,0) and I believe I want to rotate the images individually around their center point?
Also is there a way to keep the aspect ratio and scale the images with a larger view? Right now, the height and width are fixed.
*note I left out the code to load images as it not relevant to the question.
public final class PhotosView: UIView {
private let rightImageViewWidth: CGFloat = 98.0
private let rightImageViewHeight: CGFloat = 128.0
private let centerImageViewWidth: CGFloat = 128.0
private let centerImageViewHeight: CGFloat = 174.0
private let leftImageViewWidth: CGFloat = 98.0
private let leftImageViewHeight: CGFloat = 128.0
private let verticlePadding: CGFloat = 28.0
private var leftImageView: UIImageView
private var centerImageView: UIImageView
private var rightImageView: UIImageView
public init(frame: CGRect) {
self.leftImageView = UIImageView()
self.centerImageView = UIImageView()
self.rightImageView = UIImageView()
super.init(frame: frame)
self.addSubview(self.rightImageView)
self.addSubview(self.centerImageView)
self.addSubview(self.leftImageView)
}
required init(coder: NSCoder) { fatalError("Not implemented") }
public override func layoutSubviews() {
super.layoutSubviews()
let bounds = self.bounds
self.centerImageView.frame = CGRect(
x: bounds.midX - (centerImageViewWidth / 2.0), y: self.verticlePadding,
width: self.centerImageViewWidth,
height: self.centerImageViewHeight)
self.rightImageView.frame = CGRect(
x: self.centerImageView.frame.maxX - self.largePadding,
y: self.centerImageView.frame.minY + self.largePadding,
width: self.rightImageViewWidth,
height: self.rightImageViewHeight)
self.leftImageView.frame = CGRect(
x: self.centerImageView.frame.minX - self.leftImageViewWidth + self.largePadding,
y: self.centerImageView.frame.minY + self.largePadding, width: self.leftImageViewWidth,
height: self.leftImageViewHeight)
}
public override func sizeThatFits(_ size: CGSize) -> CGSize {
super.sizeThatFits(size)
let height = (2.0 * self.verticlePadding) + self.centerImageView.frame.height
return CGSize(width: size.width, height: height)
}

Related

How To Scale The Contents Of A UIView To Fit A Destination Rectangle Whilst Maintaining The Aspect Ratio?

I am trying to solve a problem without success and am hoping someone could help.
I have looked for similar posts but haven't been able to find anything which solves my problem.
My Scenario is as follows:
I have a UIView on which a number of other UIViews can be placed. These can be moved, scaled and rotated using gesture recognisers (There is no issue here).
The User is able to change the Aspect Ratio of the Main View (the Canvas) and my problem is trying to scale the content of the Canvas to fit into the new destination size.
There are a number of posts with a similar theme e.g:
calculate new size and location on a CGRect
How to create an image of specific size from UIView
But these don't address the changing of ratios multiple times.
My Approach:
When I change the aspect ratio of the canvas, I make use of AVFoundation to calculate an aspect fitted rectangle which the subviews of the canvas should fit:
let sourceRectangleSize = canvas.frame.size
canvas.setAspect(aspect, screenSize: editorLayoutGuide.layoutFrame.size)
view.layoutIfNeeded()
let destinationRectangleSize = canvas.frame.size
let aspectFittedFrame = AVMakeRect(aspectRatio:sourceRectangleSize, insideRect: CGRect(origin: .zero, size: destinationRectangleSize))
ratioVisualizer.frame = aspectFittedFrame
The Red frame is simply to visualise the Aspect Fitted Rectangle. As you can see whilst the aspect fitted rectangle is correct, the scaling of objects isn't working. This is especially true when I apply scale and rotation to the subviews (CanvasElement).
The logic where I am scaling the objects is clearly wrong:
#objc
private func setRatio(_ control: UISegmentedControl) {
guard let aspect = Aspect(rawValue: control.selectedSegmentIndex) else { return }
let sourceRectangleSize = canvas.frame.size
canvas.setAspect(aspect, screenSize: editorLayoutGuide.layoutFrame.size)
view.layoutIfNeeded()
let destinationRectangleSize = canvas.frame.size
let aspectFittedFrame = AVMakeRect(aspectRatio:sourceRectangleSize, insideRect: CGRect(origin: .zero, size: destinationRectangleSize))
ratioVisualizer.frame = aspectFittedFrame
let scale = min(aspectFittedFrame.size.width/canvas.frame.width, aspectFittedFrame.size.height/canvas.frame.height)
for case let canvasElement as CanvasElement in canvas.subviews {
canvasElement.frame.size = CGSize(
width: canvasElement.baseFrame.width * scale,
height: canvasElement.baseFrame.height * scale
)
canvasElement.frame.origin = CGPoint(
x: aspectFittedFrame.origin.x + canvasElement.baseFrame.origin.x * scale,
y: aspectFittedFrame.origin.y + canvasElement.baseFrame.origin.y * scale
)
}
}
I am enclosing the CanvasElement Class as well if this helps:
final class CanvasElement: UIView {
var rotation: CGFloat = 0
var baseFrame: CGRect = .zero
var id: String = UUID().uuidString
// MARK: - Initialization
override init(frame: CGRect) {
super.init(frame: frame)
storeState()
setupGesture()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
// MARK: - Gesture Setup
private func setupGesture() {
let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGesture(_:)))
let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(pinchGesture(_:)))
let rotateGestureRecognizer = UIRotationGestureRecognizer(target: self, action: #selector(rotateGesture(_:)))
addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(pinchGestureRecognizer)
addGestureRecognizer(rotateGestureRecognizer)
}
// MARK: - Touches
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
moveToFront()
}
//MARK: - Gestures
#objc
private func panGesture(_ sender: UIPanGestureRecognizer) {
let move = sender.translation(in: self)
transform = transform.concatenating(.init(translationX: move.x, y: move.y))
sender.setTranslation(CGPoint.zero, in: self)
storeState()
}
#objc
private func pinchGesture(_ sender: UIPinchGestureRecognizer) {
transform = transform.scaledBy(x: sender.scale, y: sender.scale)
sender.scale = 1
storeState()
}
#objc
private func rotateGesture(_ sender: UIRotationGestureRecognizer) {
rotation += sender.rotation
transform = transform.rotated(by: sender.rotation)
sender.rotation = 0
storeState()
}
// MARK: - Miscelaneous
func moveToFront() {
superview?.bringSubviewToFront(self)
}
public func rotated(by degrees: CGFloat) {
transform = transform.rotated(by: degrees)
rotation += degrees
}
func storeState() {
print("""
Element Frame = \(frame)
Element Bounds = \(bounds)
Element Center = \(center)
""")
baseFrame = frame
}
}
Any help or advise, approaches, with some actual examples would be great. Im not expecting anyone to provide full source code, but something which I could use as a basis.
Thank you for taking the time to read my question.
Here are a few thoughts and findings while playing around with this
1. Is the right scale factor being used?
The scaling you use is a bit custom and cannot be compared directly to the examples which has just 1 scale factor like 2 or 3. However, your scale factor has 2 dimensions but I see you compensate for this to get the minimum of the width and height scaling:
let scale = min(aspectFittedFrame.size.width / canvas.frame.width,
aspectFittedFrame.size.height / canvas.frame.height)
In my opinion, I don't think this is the right scale factor. To me this compares new aspectFittedFrame with the new canvas frame
when actually I believe the right scaling factor is to compare the new aspectFittedFrame with the previous canvas frame
let scale
= min(aspectFittedFrame.size.width / sourceRectangleSize.width,
aspectFittedFrame.size.height / sourceRectangleSize.height)
2. Is the scale being applied on the right values?
If you notice, the first order from 1:1 to 16:9 works quite well. However after that it does not seem to work and I believe the issue is here:
for case let canvasElement as CanvasElement in strongSelf.canvas.subviews
{
canvasElement.frame.size = CGSize(
width: canvasElement.baseFrame.width * scale,
height: canvasElement.baseFrame.height * scale
)
canvasElement.frame.origin = CGPoint(
x: aspectFittedFrame.origin.x
+ canvasElement.baseFrame.origin.x * scale,
y: aspectFittedFrame.origin.y
+ canvasElement.baseFrame.origin.y * scale
)
}
The first time, the scale works well because canvas and the canvas elements are being scaled in sync or mapped properly:
However, if you go beyond that, because you are always scaling based on the base values your aspect ratio frame and your canvas elements are out of sync
So in the example of 1:1 -> 16:9 -> 3:2
Your viewport has been scaled twice 1:1 -> 16:9 and from 16:9 -> 3:2
Whereas your elements are scaled once each time, 1:1 -> 16:9 and 1:1 -> 3:2 because you always scale from the base values
So I feel to see the values within the red viewport, you need to apply the same continuous scaling based on the previous view rather than the base view.
Just for an immediate quick fix, I update the base values of the canvas element after each change in canvas size by calling canvasElement.storeState():
for case let canvasElement as CanvasElement in strongSelf.canvas.subviews
{
canvasElement.frame.size = CGSize(
width: canvasElement.baseFrame.width * scale,
height: canvasElement.baseFrame.height * scale
)
canvasElement.frame.origin = CGPoint(
x: aspectFittedFrame.origin.x
+ canvasElement.baseFrame.origin.x * scale,
y: aspectFittedFrame.origin.y
+ canvasElement.baseFrame.origin.y * scale
)
// I added this
canvasElement.storeState()
}
The result is perhaps closer to what you want ?
Final thoughts
While this might fix your issue, you will notice that it is not possible to come back to the original state as at each step a transformation is applied.
A solution could be to store the current values mapped to a specific viewport aspect ratio and calculate the right sizes for the others so that if you needed to get back to the original, you could do that.
Couple suggestions...
First, when using your CanvasElement, panning doesn't work correctly if the view has been rotated.
So, instead of using a translate transform to move the view, change the .center itself. In addition, when panning, we want to use the translation in the superview, not in the view itself:
#objc
func panGesture(_ gest: UIPanGestureRecognizer) {
// change the view's .center instead of applying translate transform
// use translation in superview, not in self
guard let superV = superview else { return }
let translation = gest.translation(in: superV)
center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
gest.setTranslation(CGPoint.zero, in: superV)
}
Now, when we want to scale the subviews when the "Canvas" changes size, we can do this...
We'll track the "previous" bounds and use the "new bounds" to calculate the scale:
let newBounds: CGRect = bounds
let scW: CGFloat = newBounds.size.width / prevBounds.size.width
let scH: CGFloat = newBounds.size.height / prevBounds.size.height
for case let v as CanvasElement in subviews {
// reset transform before scaling / positioning
let tr = v.transform
v.transform = .identity
let w = v.frame.width * scW
let h = v.frame.height * scH
let cx = v.center.x * scW
let cy = v.center.y * scH
v.frame.size = CGSize(width: w, height: h)
v.center = CGPoint(x: cx, y: cy)
// re-apply transform
v.transform = tr
}
prevBounds = newBounds
Here's a complete sample implementation. Please note: this is Example Code Only!!! It is not intended to be "Production Ready."
import UIKit
// MARK: enum to provide strings and aspect ratio values
enum Aspect: Int, Codable, CaseIterable {
case a1to1
case a16to9
case a3to2
case a4to3
case a9to16
var stringValue: String {
switch self {
case .a1to1:
return "1:1"
case .a16to9:
return "16:9"
case .a3to2:
return "3:2"
case .a4to3:
return "4:3"
case .a9to16:
return "9:16"
}
}
var aspect: CGFloat {
switch self {
case .a1to1:
return 1
case .a16to9:
return 9.0 / 16.0
case .a3to2:
return 2.0 / 3.0
case .a4to3:
return 3.0 / 4.0
case .a9to16:
return 16.0 / 9.0
}
}
}
class EditorView: UIView {
// no code -
// just makes it easier to identify
// this view when debugging
}
// CanvasElement views will be added as subviews
// this handles the scaling / positioning when the bounds changes
// also (optionally) draws a grid (for use during development)
class CanvasView: UIView {
public var showGrid: Bool = true
private let gridLayer: CAShapeLayer = CAShapeLayer()
private var prevBounds: CGRect = .zero
// MARK: init
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
gridLayer.fillColor = UIColor.clear.cgColor
gridLayer.strokeColor = UIColor.red.cgColor
gridLayer.lineWidth = 1
layer.addSublayer(gridLayer)
}
override func layoutSubviews() {
super.layoutSubviews()
// MARK: 10 x 10 grid
if showGrid {
// draw a grid on the inside of the bounds
// so the edges are not 1/2 point width
let gridBounds: CGRect = bounds.insetBy(dx: 0.5, dy: 0.5)
let path: UIBezierPath = UIBezierPath()
let w: CGFloat = gridBounds.width / 10.0
let h: CGFloat = gridBounds.height / 10.0
var p: CGPoint = .zero
p = CGPoint(x: gridBounds.minX, y: gridBounds.minY)
for _ in 0...10 {
path.move(to: p)
path.addLine(to: CGPoint(x: p.x, y: gridBounds.maxY))
p.x += w
}
p = CGPoint(x: gridBounds.minX, y: gridBounds.minY)
for _ in 0...10 {
path.move(to: p)
path.addLine(to: CGPoint(x: gridBounds.maxX, y: p.y))
p.y += h
}
gridLayer.path = path.cgPath
}
// MARK: update subviews
// we only want to move/scale the subviews if
// the bounds has > 0 width and height and
// prevBounds has > 0 width and height and
// the bounds has changed
guard bounds != prevBounds,
bounds.width > 0, prevBounds.width > 0,
bounds.height > 0, prevBounds.height > 0
else { return }
let newBounds: CGRect = bounds
let scW: CGFloat = newBounds.size.width / prevBounds.size.width
let scH: CGFloat = newBounds.size.height / prevBounds.size.height
for case let v as CanvasElement in subviews {
// reset transform before scaling / positioning
let tr = v.transform
v.transform = .identity
let w = v.frame.width * scW
let h = v.frame.height * scH
let cx = v.center.x * scW
let cy = v.center.y * scH
v.frame.size = CGSize(width: w, height: h)
v.center = CGPoint(x: cx, y: cy)
// re-apply transform
v.transform = tr
}
prevBounds = newBounds
}
override var bounds: CGRect {
willSet {
prevBounds = bounds
}
}
}
// self-contained Pan/Pinch/Rotate view
// set allowSimultaneous to TRUE to enable
// simultaneous gestures
class CanvasElement: UIView, UIGestureRecognizerDelegate {
public var allowSimultaneous: Bool = false
// MARK: init
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
isUserInteractionEnabled = true
isMultipleTouchEnabled = true
let panG = UIPanGestureRecognizer(target: self, action: #selector(panGesture(_:)))
let pinchG = UIPinchGestureRecognizer(target: self, action: #selector(pinchGesture(_:)))
let rotateG = UIRotationGestureRecognizer(target: self, action: #selector(rotateGesture(_:)))
[panG, pinchG, rotateG].forEach { g in
g.delegate = self
addGestureRecognizer(g)
}
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
// unwrap optional superview
guard let superV = superview else { return }
superV.bringSubviewToFront(self)
}
// MARK: UIGestureRecognizer Methods
#objc
func panGesture(_ gest: UIPanGestureRecognizer) {
// change the view's .center instead of applying translate transform
// use translation in superview, not in self
guard let superV = superview else { return }
let translation = gest.translation(in: superV)
center = CGPoint(x: center.x + translation.x, y: center.y + translation.y)
gest.setTranslation(CGPoint.zero, in: superV)
}
#objc
func pinchGesture(_ gest: UIPinchGestureRecognizer) {
// apply scale transform
transform = transform.scaledBy(x: gest.scale, y: gest.scale)
gest.scale = 1
}
#objc
func rotateGesture(_ gest : UIRotationGestureRecognizer) {
// apply rotate transform
transform = transform.rotated(by: gest.rotation)
gest.rotation = 0
}
// MARK: UIGestureRecognizerDelegate Methods
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return allowSimultaneous
}
}
// example view controller
// Aspect Ratio segmented control
// changes the Aspect Ratio of the Editor View
// includes triple-tap gesture to cycle through
// 3 "starting subview" layouts
class ViewController: UIViewController, UIGestureRecognizerDelegate {
let editorView: EditorView = {
let v = EditorView()
v.backgroundColor = UIColor(white: 0.9, alpha: 1.0)
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let canvasView: CanvasView = {
let v = CanvasView()
v.backgroundColor = .yellow
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// segmented control for selecting Aspect Ratio
let aspectRatioSeg: UISegmentedControl = {
let v = UISegmentedControl()
v.setContentCompressionResistancePriority(.required, for: .vertical)
v.setContentHuggingPriority(.required, for: .vertical)
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// this will be changed by the Aspect Ratio segmented control
var evAspectConstraint: NSLayoutConstraint!
// used to cycle through intitial subviews layout
var layoutMode: Int = 0
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.99, alpha: 1.0)
// container view for laying out editor view
let containerView: UIView = {
let v = UIView()
v.backgroundColor = .cyan
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
// setup the aspect ratio segmented control
for (idx, m) in Aspect.allCases.enumerated() {
aspectRatioSeg.insertSegment(withTitle: m.stringValue, at: idx, animated: false)
}
// add canvas view to editor view
editorView.addSubview(canvasView)
// add editor view to container view
containerView.addSubview(editorView)
// add container view to self's view
view.addSubview(containerView)
// add UI Aspect Ratio segmented control to self's view
view.addSubview(aspectRatioSeg)
// always respect the safe area
let safeG = view.safeAreaLayoutGuide
// editor view inset from container view sides
let evInset: CGFloat = 0
// canvas view inset from editor view sides
let cvInset: CGFloat = 0
// these sets of constraints will make the Editor View and the Canvas View
// as large as their superviews (with "Inset Edge Padding" if set above)
// while maintaining aspect ratios and centering
let evMaxW = editorView.widthAnchor.constraint(lessThanOrEqualTo: containerView.widthAnchor, constant: -evInset)
let evMaxH = editorView.heightAnchor.constraint(lessThanOrEqualTo: containerView.heightAnchor, constant: -evInset)
let evW = editorView.widthAnchor.constraint(equalTo: containerView.widthAnchor)
let evH = editorView.heightAnchor.constraint(equalTo: containerView.heightAnchor)
evW.priority = .required - 1
evH.priority = .required - 1
let cvMaxW = canvasView.widthAnchor.constraint(lessThanOrEqualTo: editorView.widthAnchor, constant: -cvInset)
let cvMaxH = canvasView.heightAnchor.constraint(lessThanOrEqualTo: editorView.heightAnchor, constant: -cvInset)
let cvW = canvasView.widthAnchor.constraint(equalTo: editorView.widthAnchor)
let cvH = canvasView.heightAnchor.constraint(equalTo: editorView.heightAnchor)
cvW.priority = .required - 1
cvH.priority = .required - 1
// editor view starting aspect ratio
// this is changed by the segmented control
let editorAspect: Aspect = .a1to1
aspectRatioSeg.selectedSegmentIndex = editorAspect.rawValue
evAspectConstraint = editorView.heightAnchor.constraint(equalTo: editorView.widthAnchor, multiplier: editorAspect.aspect)
// we can set the Aspect Ratio of the CanvasView here
// it will maintain its Aspect Ratio independent of
// the Editor View's Aspect Ratio
let canvasAspect: Aspect = .a1to1
NSLayoutConstraint.activate([
containerView.topAnchor.constraint(equalTo: safeG.topAnchor),
containerView.leadingAnchor.constraint(equalTo: safeG.leadingAnchor),
containerView.trailingAnchor.constraint(equalTo: safeG.trailingAnchor),
editorView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
editorView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor),
evMaxW, evMaxH,
evW, evH,
evAspectConstraint,
canvasView.centerXAnchor.constraint(equalTo: editorView.centerXAnchor),
canvasView.centerYAnchor.constraint(equalTo: editorView.centerYAnchor),
cvMaxW, cvMaxH,
cvW, cvH,
canvasView.heightAnchor.constraint(equalTo: canvasView.widthAnchor, multiplier: canvasAspect.aspect),
aspectRatioSeg.topAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 8.0),
aspectRatioSeg.bottomAnchor.constraint(equalTo: safeG.bottomAnchor, constant: -8.0),
aspectRatioSeg.centerXAnchor.constraint(equalTo: safeG.centerXAnchor),
aspectRatioSeg.widthAnchor.constraint(greaterThanOrEqualTo: safeG.widthAnchor, multiplier: 0.5),
aspectRatioSeg.widthAnchor.constraint(lessThanOrEqualTo: safeG.widthAnchor),
])
aspectRatioSeg.addTarget(self, action: #selector(aspectRatioSegmentChanged(_:)), for: .valueChanged)
// triple-tap anywhere to "reset" the 3 subviews
// cycling between starting sizes/positions
let tt = UITapGestureRecognizer(target: self, action: #selector(resetCanvas))
tt.numberOfTapsRequired = 3
tt.delaysTouchesEnded = false
view.addGestureRecognizer(tt)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
// we don't have the frames in viewDidLoad,
// so wait until now to add the CanvasElement views
resetCanvas()
}
#objc func resetCanvas() {
canvasView.subviews.forEach { v in
v.removeFromSuperview()
}
// add 3 views to the canvas
let v1 = CanvasElement()
v1.backgroundColor = .systemYellow
let v2 = CanvasElement()
v2.backgroundColor = .systemGreen
let v3 = CanvasElement()
v3.backgroundColor = .systemBlue
// default size of subviews is 2/10ths the width of the canvas
let w: CGFloat = canvasView.bounds.width * 0.2
[v1, v2, v3].forEach { v in
v.frame = CGRect(x: 0, y: 0, width: w, height: w)
canvasView.addSubview(v)
// if we want to allow simultaneous gestures
// i.e. pan/scale/rotate all at the same time
//v.allowSimultaneous = true
}
switch (layoutMode % 3) {
case 1:
// top-left corner
// center at 1.5 times the size
// bottom-right corner
v1.frame.origin = CGPoint(x: 0, y: 0)
v2.frame.size = CGSize(width: w * 1.5, height: w * 1.5)
v2.center = CGPoint(x: canvasView.bounds.midX, y: canvasView.bounds.midY)
v3.center = CGPoint(x: canvasView.bounds.maxX - w * 0.5, y: canvasView.bounds.maxY - w * 0.5)
()
case 2:
// different sized views
v1.frame = CGRect(x: 0, y: 0, width: w * 0.5, height: w)
v2.frame.size = CGSize(width: w, height: w)
v2.center = CGPoint(x: canvasView.bounds.midX, y: canvasView.bounds.midY)
v3.frame.size = CGSize(width: w, height: w * 0.5)
v3.center = CGPoint(x: canvasView.bounds.maxX - v3.frame.width * 0.5, y: canvasView.bounds.maxY - v3.frame.height * 0.5)
()
default:
// on a "diagonal"
// starting at top-left corner
v1.frame.origin = CGPoint(x: 0, y: 0)
v2.frame.origin = CGPoint(x: w, y: w)
v3.frame.origin = CGPoint(x: w * 2, y: w * 2)
()
}
layoutMode += 1
}
#objc func aspectRatioSegmentChanged(_ sender: Any?) {
if let seg = sender as? UISegmentedControl,
let r = Aspect.init(rawValue: seg.selectedSegmentIndex)
{
evAspectConstraint.isActive = false
evAspectConstraint = editorView.heightAnchor.constraint(equalTo: editorView.widthAnchor, multiplier: r.aspect)
evAspectConstraint.isActive = true
}
}
}
Some sample screenshots...
Yellow is the Canvas view... with optional red 10x10 grid
Gray is the Editor view... this is the view that changes Aspect Ratio
Cyan is the "Container" view.... Editor view fits/centers itself
Note that the Canvas view can be set to something other than a square (1:1 ratio). For example, here it's set to 9:16 ratio -- and maintains its Aspect Ratio independent of the Editor view Aspect Ratio:
With this example controller, triple-tap anywhere to cycle through 3 "starting layouts":
Maybe you can make the three rectangles in a view. And then you can keep the aspect-ratio for the view.
If you are using autolayout and Snapkit. The constrains maybe like this:
view.snp.makeConstraints { make in
make.width.height.lessThanOrEqualToSuperview()
make.centerX.centerY.equalToSuperview()
make.width.equalTo(view.snp.height)
make.width.height.equalToSuperview().priority(.high)
}
So this view will be aspect-fit in superview.
Back to children in this view. If you want to scale every child when view's frame changed, you should add contrains too. Or you can use autoresizingMask, it maybe simpler.
If you didn't want to use autolayout. Maybe you can try transform. When you transform some view, the children in this view will be changed too.
// The scale depends on the aspect-ratio of superview.
view.transform = CGAffineTransformMakeScale(0.5, 0.5);

Building a circular facepile of profile pictures in Swift: how to have the last photo tucked under the first?

I am trying to build a UIView that has a few UIImageViews arranged in a circular, overlapping manner (see image below). Let's say we have N images. Drawing out the first N - 1 is easy, just use sin/cos functions to arrange the centers of the UIImageViews around a circle. The problem is with the last image that seemingly has two z-index values! I know this is possible since kik messenger has similar group profile photos.
The best idea I have come up so far is taking the last image, split into something like "top half" and "bottom half" and assign different z-values for each. This seems doable when the image is the left-most one, but what happens if the image is the top most? In this case, I would need to split left and right instead of top and bottom.
Because of this problem, it's probably not top, left, or right, but more like a split across some imaginary axis from the center of the overall facepile through the center of the UIImageView. How would I do that?!
Below Code Will Layout UIImageView's in Circle
You would need to import SDWebImage and provide some image URLs to run the code below.
import Foundation
import UIKit
import SDWebImage
class EventDetailsFacepileView: UIView {
static let dimension: CGFloat = 66.0
static let radius: CGFloat = dimension / 1.68
private var profilePicViews: [UIImageView] = []
var profilePicURLs: [URL] = [] {
didSet {
updateView()
}
}
func updateView() {
self.profilePicViews = profilePicURLs.map({ (profilePic) -> UIImageView in
let imageView = UIImageView()
imageView.sd_setImage(with: profilePic)
imageView.roundImage(imageDimension: EventDetailsFacepileView.dimension, showsBorder: true)
imageView.sd_imageTransition = .fade
return imageView
})
self.profilePicViews.forEach { (imageView) in
self.addSubview(imageView)
}
self.setNeedsLayout()
self.layer.borderColor = UIColor.green.cgColor
self.layer.borderWidth = 2
}
override func layoutSubviews() {
super.layoutSubviews()
let xOffset: CGFloat = 0
let yOffset: CGFloat = 0
let center = CGPoint(x: self.bounds.size.width / 2, y: self.bounds.size.height / 2)
let radius: CGFloat = EventDetailsFacepileView.radius
let angleStep: CGFloat = 2 * CGFloat(Double.pi) / CGFloat(profilePicViews.count)
var count = 0
for profilePicView in profilePicViews {
let xPos = center.x + CGFloat(cosf(Float(angleStep) * Float(count))) * (radius - xOffset)
let yPos = center.y + CGFloat(sinf(Float(angleStep) * Float(count))) * (radius - yOffset)
profilePicView.frame = CGRect(origin: CGPoint(x: xPos, y: yPos),
size: CGSize(width: EventDetailsFacepileView.dimension, height: EventDetailsFacepileView.dimension))
count += 1
}
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let requiredSize = EventDetailsFacepileView.dimension + EventDetailsFacepileView.radius
return CGSize(width: requiredSize,
height: requiredSize)
}
}
I don't think you'll have much success trying to split images to get over/under z-indexes.
One approach is to use masks to make it appear that the image views are overlapped.
The general idea would be:
subclass UIImageView
in layoutSubviews()
apply cornerRadius to layer to make the image round
get a rect from the "overlapping view"
convert that rect to local coordinates
expand that rect by the desired width of the "outline"
get an oval path from that rect
combine it with a path from self
apply it as a mask layer
Here is an example....
I was not entirely sure what your sizing calculations were doing... trying to use your EventDetailsFacepileView as-is gave me small images in the lower-right corner of the view?
So, I modified your EventDetailsFacepileView in a couple ways:
uses local images named "pro1" through "pro5" (you should be able to replace with your SDWebImage)
uses auto-layout constraints instead of explicit frames
uses MyOverlapImageView class to handle the masking
Code - no #IBOutlet connections, so just set a blank view controller to OverlapTestViewController:
class OverlapTestViewController: UIViewController {
let facePileView = MyFacePileView()
override func viewDidLoad() {
super.viewDidLoad()
facePileView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(facePileView)
facePileView.dimension = 120
let sz = facePileView.sizeThatFits(.zero)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
facePileView.widthAnchor.constraint(equalToConstant: sz.width),
facePileView.heightAnchor.constraint(equalTo: facePileView.widthAnchor),
facePileView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
facePileView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])
facePileView.profilePicNames = [
"pro1", "pro2", "pro3", "pro4", "pro5"
]
}
}
class MyFacePileView: UIView {
var dimension: CGFloat = 66.0
lazy var radius: CGFloat = dimension / 1.68
private var profilePicViews: [MyOverlapImageView] = []
var profilePicNames: [String] = [] {
didSet {
updateView()
}
}
func updateView() {
self.profilePicViews = profilePicNames.map({ (profilePic) -> MyOverlapImageView in
let imageView = MyOverlapImageView()
if let img = UIImage(named: profilePic) {
imageView.image = img
}
return imageView
})
// add MyOverlapImageViews to self
// and set width / height constraints
self.profilePicViews.forEach { (imageView) in
self.addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.widthAnchor.constraint(equalToConstant: dimension).isActive = true
imageView.heightAnchor.constraint(equalTo: imageView.widthAnchor).isActive = true
}
// start at "12 o'clock"
var curAngle: CGFloat = .pi * 1.5
// angle increment
let incAngle: CGFloat = ( 360.0 / CGFloat(self.profilePicViews.count) ) * .pi / 180.0
// calculate position for each image view
// set center constraints
self.profilePicViews.forEach { imgView in
let xPos = cos(curAngle) * radius
let yPos = sin(curAngle) * radius
imgView.centerXAnchor.constraint(equalTo: centerXAnchor, constant: xPos).isActive = true
imgView.centerYAnchor.constraint(equalTo: centerYAnchor, constant: yPos).isActive = true
curAngle += incAngle
}
// set "overlapView" property for each image view
let n = self.profilePicViews.count
for i in (1..<n).reversed() {
self.profilePicViews[i].overlapView = self.profilePicViews[i-1]
}
self.profilePicViews[0].overlapView = self.profilePicViews[n - 1]
self.layer.borderColor = UIColor.green.cgColor
self.layer.borderWidth = 2
}
override func sizeThatFits(_ size: CGSize) -> CGSize {
let requiredSize = dimension * 2.0 + radius / 2.0
return CGSize(width: requiredSize,
height: requiredSize)
}
}
class MyOverlapImageView: UIImageView {
// reference to the view that is overlapping me
weak var overlapView: MyOverlapImageView?
// width of "outline"
var outlineWidth: CGFloat = 6
override func layoutSubviews() {
super.layoutSubviews()
// make image round
layer.cornerRadius = bounds.size.width * 0.5
layer.masksToBounds = true
let mask = CAShapeLayer()
if let v = overlapView {
// get bounds from overlapView
// converted to self
// inset by outlineWidth (negative numbers will make it grow)
let maskRect = v.convert(v.bounds, to: self).insetBy(dx: -outlineWidth, dy: -outlineWidth)
// oval path from mask rect
let path = UIBezierPath(ovalIn: maskRect)
// path from self bounds
let clipPath = UIBezierPath(rect: bounds)
// append paths
clipPath.append(path)
mask.path = clipPath.cgPath
mask.fillRule = .evenOdd
// apply mask
layer.mask = mask
}
}
}
Result:
(I grabbed random images by searching google for sample profile pictures)

How can we achieve dynamic items with dynamic content in custom table cell?

How can I achieve the dynamic no of items with dynamic content size one after other and in next line if the content gets more than superview in a custom table view cell?
Please refer image for question clarification.
The question you posted is a bit long and actually requests for multiple things since you didn't post what you have tried and what part you are having trouble with. There are many ways of doing this so let's start with normal label:
An UILabel can have attributed string under attributedText which can be used to achieve the visual design you are showing in your image. To lay them out horizontally we could use sizeToFit to get their width. Assume something like:
func layoutLabels(_ labels: [UILabel], inView view: UIView, horizontalSeparator: CGFloat = 4.0) {
var x: CGFloat = 0.0
labels.forEach { label in
label.sizeToFit() // Will make it's size just right
label.frame = CGRect(x: x, y: 0.0, width: label.frame.width, height: label.frame.height)
x = label.frame.maxX + horizontalSeparator
view.addSubview(label)
}
}
Now to add vertical part we simply need to add maximum width and also add y component:
func layoutLabels(_ labels: [UILabel], inView view: UIView, maximumWidth: CGFloat, lineHeight: CGFloat, horizontalSeparator: CGFloat = 4.0, verticalSeparator: CGFloat = 2.0) {
var x: CGFloat = 0.0
var y: CGFloat = 0.0
labels.forEach { label in
label.sizeToFit() // Will make it's size just right
var newX = x + label.frame.width
if newX > maximumWidth {
if x == 0.0 {
// We are at the beginning of the line and a single label is simply too large. We will need to reduce it's width
label.frame = CGRect(x: x, y: y, width: maximumWidth, height: label.frame.height)
} else {
// We should go into new line
x = 0.0
y += lineHeight
label.frame = CGRect(x: x, y: y, width: min(label.frame.width, maximumWidth), height: label.frame.height)
newX = x + label.frame.width
}
} else {
label.frame = CGRect(x: x, y: y, width: min(label.frame.width, maximumWidth), height: label.frame.height)
}
x = newX + horizontalSeparator
view.addSubview(label)
}
}
This should now let you fill labels the way as on your image. We are also checking for line breaks and labels that can not fit overall width of your view. So now we need to define what is the actual size of your view. y should return that but we need to be able to use it in a table view cell. So we can simply return it:
func layoutLabels(_ labels: [UILabel], inView view: UIView, maximumWidth: CGFloat, lineHeight: CGFloat, horizontalSeparator: CGFloat = 4.0, verticalSeparator: CGFloat = 2.0) -> CGFloat {
...
return labels.count > 0 ? y + lineHeight : 0.0
}
Now in your table view cell it might be best to use some constraint outlet:
protocol Tag {
func createLabel() -> UILabel
}
class Cell: UITableViewCell {
#IBOutlet private var tagsPanel: UIView!
#IBOutlet private var tagsPanelHeightConstraint: NSLayoutConstraint?
var tags: [Tag]!
func refresh() {
tagsPanel.subviews.forEach { $0.removeFromSuperview() } // Ugly, needs improvement
let height = layoutLabels(tags.map { $0.createLabel() }, inView: tagsPanel, maximumWidth: tagsPanel.frame.width, lineHeight: 40.0)
tagsPanelHeightConstraint?.constant = height
}
}
Now the problem is that your table view needs to refresh because your table view cell might have resized. You will need to have a reference to your table view and simple call begin/end updates on it.
var tags: [Tag]!
weak var tableView: UITableView? // Assigned in cellForRowAtIndexPath
func refresh() {
tagsPanel.subviews.forEach { $0.removeFromSuperview() } // Ugly, needs improvement
let height = layoutLabels(tags.map { $0.createLabel() }, inView: tagsPanel, maximumWidth: tagsPanel.frame.width, lineHeight: 40.0)
if let tagsPanelHeightConstraint = tagsPanelHeightConstraint, tagsPanelHeightConstraint.constant != height {
tagsPanelHeightConstraint.constant = height
tableView?.beginUpdates()
tableView?.endUpdates()
}
}
Good luck.

Creating circle and using it as "bar graph"

I want to create a circle with an inner circle that looks like the image below. I'm having trouble with the inner circle and I don't know how to create it so it's easy to adjust percentage (like the image is showing).
So far I have this CircleGraph class which can draw the ouster circle and an inner circle which can only draw 50 %.
import Foundation
import UIKit
class CircleGraph: UIView
{
// Only override drawRect: if you perform custom drawing.
// An empty implementation adversely affects performance during animation.
override func drawRect(rect: CGRect)
{
super.drawRect(rect)
// Outer circle
Colors().getMainColor().setFill()
let outerPath = UIBezierPath(ovalInRect: rect)
outerPath.fill()
// inner circle so far
let percentage = 0.5
UIColor.whiteColor().setFill()
let circlePath = UIBezierPath(arcCenter: CGPoint(x: rect.height/2,y: rect.height/2), radius: CGFloat(rect.height/2), startAngle: CGFloat(-M_PI_2), endAngle:CGFloat(M_PI * 2 * percentage - M_PI_2), clockwise: true)
circlePath.fill()
}
}
Can anyone assist me?
What I want is something simliar to the image below:
I would go for the easy solution and create a UIView with a UIView and UILabel as subviews. If you use something like:
// To make it round
let width = self.frame.width
self.view.layer.cornerRadius = width * 0.5
self.view.layer.masksToBounds = true
for each of the sublayers. If you have set the background colour of the UIView's background layer to something like Red and the UIView layer above to have a whiteish background colour with alpha 0.5 than you already achieve this effect.
If you do not know how to proceed with this tip ill try to provide a code sample.
-- EDIT --
Here is the code sample:
import UIKit
class CircleView: UIView {
var percentage : Int?
var transparency : CGFloat?
var bottomLayerColor : UIColor?
var middleLayerColor : UIColor?
init(frame : CGRect, percentage : Int, transparency : CGFloat, bottomLayerColor : UIColor, middleLayerColor : UIColor) {
super.init(frame : frame)
self.percentage = percentage
self.transparency = transparency
self.bottomLayerColor = bottomLayerColor
self.middleLayerColor = middleLayerColor
viewDidLoad()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
viewDidLoad()
}
func viewDidLoad() {
let width = self.frame.width
let height = self.frame.height
let textFrame = CGRectMake(0, 0, width, height)
guard let percentage = self.percentage
else {
print("Error")
return
}
let newHeight = (CGFloat(percentage)/100.0)*height
let middleFrame = CGRectMake(0,height - newHeight, width, newHeight)
// Set Background Color
if let bottomLayerColor = self.bottomLayerColor {
self.backgroundColor = bottomLayerColor
}
// Make Bottom Layer Round
self.layer.cornerRadius = width * 0.5
self.layer.masksToBounds = true
// Create Middle Layer
let middleLayer = UIView(frame: middleFrame)
if let middleLayerColor = self.middleLayerColor {
middleLayer.backgroundColor = middleLayerColor
}
if let transparency = self.transparency {
middleLayer.alpha = transparency
}
// The Label
let percentageLayer = UILabel(frame: textFrame)
percentageLayer.textAlignment = NSTextAlignment.Center
percentageLayer.textColor = UIColor.whiteColor()
if let percentage = self.percentage {
percentageLayer.text = "\(percentage)%"
}
// Add Subviews
self.addSubview(middleLayer)
self.addSubview(percentageLayer)
}
}
To use in a View Controller:
let redColor = UIColor.redColor()
let blueColor = UIColor.blueColor()
let frame = CGRectMake(50, 50, 100, 100)
// 50% Example
let circleView = CircleView(frame: frame, percentage: 50, transparency: 0.5, bottomLayerColor: redColor, middleLayerColor: blueColor)
self.view.addSubview(circleView)
// 33% Example
let newFrame = CGRectMake(50, 150, 120, 120)
let newCircleView = CircleView(frame: newFrame, percentage: 33, transparency: 0.7, bottomLayerColor: UIColor.redColor(), middleLayerColor: UIColor.whiteColor())
self.view.addSubview(newCircleView)
This will yield something like this:

trouble creating a scrollable UIView (swift + Interface Builder)

Please do not immediately discredit this a duplicate, I have been on stackoverflow for like 2 days now trying any and every way to do this, read a lot of blog articles (which might have been slightly dated) and still no luck.
Basically, I have a custom UIView which is meant to draw a lot of circles (it's just the starting point) and I cannot get the view to scroll down to the ones apart from the visible circles on initial load. I made the for loop for about 200 of them to be sure there is something to scroll to.
here is my view code:
import UIKit
class Draw2D: UIView {
required init(coder aDecoder: NSCoder) {
super.init(coder: aDecoder);
}
override func drawRect(rect: CGRect) {
let context = UIGraphicsGetCurrentContext();
CGContextSetLineWidth(context, 2.0);
let colorSpace = CGColorSpaceCreateDeviceRGB();
let components: [CGFloat] = [0.0, 0.0, 1.0, 1.0];
let color = CGColorCreate(colorSpace, components);
CGContextSetStrokeColorWithColor(context, color);
var ctr = 0;
var ctr2 = 0;
for(var i:Int = 1; i<200; i++){
var ii: CGFloat=CGFloat(10+ctr);
var iii: CGFloat = CGFloat(30+ctr2);
CGContextStrokeEllipseInRect(context, CGRectMake(ii, iii, 33, 33));
if(ctr<200)
{
ctr+=160;
}else{
ctr=0;
ctr2+=50;
}
}
CGContextStrokePath(context);
}
}
In interface builder, I set the view class to Draw2D and the controller to my custom ViewController class. The view controller class is currently a standard one with no additional options added.
I tried dragging a Scroll View on the screen, I tried deleting the standard view, adding the scroll view and then putting my Draw2D view on top of that and it didn't work. I tried changing the size of the scroll view to make it smaller than my content (draw2d) view and nothing. I tried unchecking auto-layout and also changing simulated size to freeform as per certain tutorials I found online.
I also at one point tried creating a scroll view programmatically in the UIView and adding it as a subview which didn't help, maybe I did it wrong, I don't know but I'm really running out of options and sources to generate ideas from.
First, you can inherit from UIScrollView directly:
class Draw2D: UIScrollView {
Next, you need to set the content size of the UIScrollView to the height of the elements you inserted. For instance, let's say you have 4 circles:
let radius: CGFloat = 200
var contentHeight: CGFloat = 0
for i in 0...4 {
// initialize circle here
var circle = UIView()
// customize the view
circle.layer.cornerRadius = radius/2
circle.backgroundColor = UIColor.redColor()
// set the frame of the circle
circle.frame = CGRect(x: 0, y: CGFloat(i)*radius, width: radius, height: radius)
// add to scroll view
self.addSubview(circle)
// keep track of the height of the content
contentHeight += radius
}
At this point you have 4 circles one after the other, summing up to 800 px in height. Thus, you would set the content size of the scroll view as follows:
self.contentSize = CGSize(width: self.frame.width, height: contentHeight)
As far as contentHeight > device screen height then your view will scroll.
Full working code below:
import UIKit
import Foundation
class Draw2D: UIScrollView {
override init(frame: CGRect) {
super.init(frame: frame)
let radius: CGFloat = 200
var contentHeight: CGFloat = 0
for i in 0...4 {
// initialize circle here
var circle = UIView()
// customize the view
circle.layer.cornerRadius = radius/2
circle.backgroundColor = UIColor.redColor()
// set the frame of the circle
circle.frame = CGRect(x: 0, y: CGFloat(i)*radius, width: radius, height: radius)
// add to scroll view
self.addSubview(circle)
// keep track of the height of the content
contentHeight += radius
}
self.contentSize = CGSize(width: self.frame.width, height: contentHeight)
}
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

Resources