I'm generating UIButtons dynamically and placing them in a dynamically sized UIImageView. I created the buttons in a loop, then in another loop I set their constraints based upon the available space . It all works, i.e., the buttons and their text show up nicely sorted BUT on clicking them nothing happens. button.isUserinteractionIsEnables = YES.
When logging the buttons in by final loop I they do not have Y,X coordinates. I think my anchors whould take care of that. Or is combining CGRect with anchors always a bad idea?
EDIT: Fixed
On button's parentclass init I had to set .isUserInteractionEnabled = true;
If at any point, a view has user interaction disabled, then it will refuse to perform its standard action, which includes not passing the event to any of its subviews
import UIKit
class SuggenstionCloud: UIImageView {
override init(image: UIImage?) {
super.init(image: image)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setConstraints(
topAnchor : NSLayoutAnchor<NSLayoutYAxisAnchor>, topConstant: CGFloat,
bottomAnchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, bottomConstant: CGFloat,
trailingAnchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, trailingConstant: CGFloat,
leadingAnchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, leadingConstant: CGFloat)
{
self.isUserInteractionEnabled = true
self.contentMode = .scaleToFill
self.topAnchor.constraint(equalTo: topAnchor, constant: topConstant).isActive = true;
self.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant).isActive = true;
self.trailingAnchor.constraint(equalTo: trailingAnchor, constant: trailingConstant).isActive = true;
self.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leadingConstant).isActive = true;
}
public func setLabels(weightedTags: [String: Int], selectedTags: [String]) {
let buttons : [UIButton] = createButtons(weightedTags: weightedTags);
createLayout(buttons: buttons)
}
private func createButton(buttonText: String) -> UIButton {
let button = UIButton()
button.setTitle(buttonText, for: .normal)
button.setTitleColor(.black, for: .normal)
button.titleLabel?.font = UIFont(name: "Avenir-Light", size: 20.0)
button.translatesAutoresizingMaskIntoConstraints = false;
self.addSubview(button)
button.frame = CGRect(x:0,y:0,width:button.intrinsicContentSize.width, height: button.intrinsicContentSize.height)
button.addTarget(self, action: #selector(self.onButtonPresed(_:)), for: .touchUpInside);
return button;
}
#objc func onButtonPresed(_ sender: UIButton) {
// guard let label = sender.titleLabel else {return}
print("Button : \(sender) poressed")
}
private func createButtons(weightedTags: [String: Int]) -> [UIButton] {
var buttons : [UIButton] = [];
for tag in weightedTags {
buttons.append(createButton(buttonText: tag.key))
}
return buttons;
}
private func createLayout(buttons : [UIButton]) {
if buttons.count == 0 { return }
let topPadding : CGFloat = 30;
let sidePadding : CGFloat = 32;
let padding : CGFloat = 10;
let availableHeight : CGFloat = self.frame.height + (-2 * topPadding)
let availableWidth : CGFloat = self.frame.width + (-2 * sidePadding)
var i = 0;
var totalHeight : CGFloat = topPadding
var rowLength : CGFloat = 0;
var rowCount : Int = 0;
var lastButton : UIButton!
for button in buttons {
if totalHeight > availableHeight { print("Cloud out of space"); return }
let buttonWidth = button.intrinsicContentSize.width;
let buttonHeight = button.intrinsicContentSize.height;
if rowLength == 0 && rowCount == 0
{
print("FirstButtonLabel \(String(button.titleLabel!.text!))")
setFirstButtonConstraint(button: button, totalHeight: totalHeight, sidePadding: sidePadding)
rowLength += buttonWidth + sidePadding + 5; // FIX annoying first row image overlap
}
else if rowLength + buttonWidth + padding < availableWidth
{
setConstraint(button: button, lastButton: lastButton, totalHeight: totalHeight, padding: padding)
rowLength += buttonWidth + padding;
}
else
{
print("Out of space")
totalHeight += buttonHeight + padding
rowLength = buttonWidth + sidePadding;
rowCount += 1;
setNewRowConstraint(button: button, totalHeight:totalHeight , sidePadding: sidePadding)
}
i += 1;
lastButton = button
print(button.isUserInteractionEnabled)
print(button.allTargets)
print(button)
}
}
private func setFirstButtonConstraint(button: UIButton, totalHeight: CGFloat, sidePadding: CGFloat) {
button.topAnchor.constraint(equalTo: self.topAnchor, constant: totalHeight).isActive = true;
button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: sidePadding + 5).isActive = true;
}
private func setConstraint(button: UIButton, lastButton: UIButton, totalHeight: CGFloat, padding:CGFloat) {
button.leadingAnchor.constraint(equalTo: lastButton.trailingAnchor, constant: padding).isActive = true;
button.topAnchor.constraint(equalTo: self.topAnchor, constant: totalHeight).isActive = true;
}
private func setNewRowConstraint(button: UIButton, totalHeight: CGFloat, sidePadding: CGFloat) {
button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: sidePadding).isActive = true;
button.topAnchor.constraint(equalTo: self.topAnchor, constant: totalHeight).isActive = true;
}
}
The Print statements provide the following output:
true
[AnyHashable(<unFatMobile.SuggenstionCloud: 0x7fb5fae028e0; baseClass = UIImageView; frame = (10 280; 375 317); opaque = NO; userInteractionEnabled = NO; layer = <CALayer: 0x6000005de920>>)]
<UIButton: 0x7fb5fac2d840; frame = (0 0; 41 40); opaque = NO; layer = <CALayer: 0x6000005d0fe0>>
I think I might be doing something wrong with the class lifecycle methods.
I see this has already been answered in the comments but a little background.
When the user taps on the screen, it is the top level view that gets the event. If it has isUserInteractionEnabled set to true, then it performs its standard action (each UIView can do something different on a tap.) The base UIView's standard action is to figure out which subview was tapped on as pass the event to that subview. In this way, the tap event goes down the hierarchy of views from most general to most specific.
If at any point, a view has user interaction disabled, then it will refuse to perform its standard action, which includes not passing the event to any of its subviews.
Another way to accidentally make a UIButton not tapeable is to draw it outside its parent view's bounds. Views only pass events to sub-views that were drawn in their bounds.
Related
So I have a UIButton and I'm setting the title in it to a string that is dynamic in length. I want the width of the titleLabel to be half of the screen width. I've tried using .sizeToFit() but this causes the button to use the CGSize before the constraint was applied to the titleLabel. I tried using .sizeThatFits(button.titleLabel?.intrinsicContentSize) but this also didn't work. I think the important functions below are the init() & presentCallout(), but I'm showing the entire class just for a more complete understanding. The class I'm playing with looks like:
class CustomCalloutView: UIView, MGLCalloutView {
var representedObject: MGLAnnotation
// Allow the callout to remain open during panning.
let dismissesAutomatically: Bool = false
let isAnchoredToAnnotation: Bool = true
// https://github.com/mapbox/mapbox-gl-native/issues/9228
override var center: CGPoint {
set {
var newCenter = newValue
newCenter.y -= bounds.midY
super.center = newCenter
}
get {
return super.center
}
}
lazy var leftAccessoryView = UIView() /* unused */
lazy var rightAccessoryView = UIView() /* unused */
weak var delegate: MGLCalloutViewDelegate?
let tipHeight: CGFloat = 10.0
let tipWidth: CGFloat = 20.0
let mainBody: UIButton
required init(representedObject: MGLAnnotation) {
self.representedObject = representedObject
self.mainBody = UIButton(type: .system)
super.init(frame: .zero)
backgroundColor = .clear
mainBody.backgroundColor = .white
mainBody.tintColor = .black
mainBody.contentEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
mainBody.layer.cornerRadius = 4.0
addSubview(mainBody)
// I thought this would work, but it doesn't.
// mainBody.translatesAutoresizingMaskIntoConstraints = false
// mainBody.topAnchor.constraint(equalTo: self.topAnchor).isActive = true
// mainBody.leftAnchor.constraint(equalTo: self.rightAnchor).isActive = true
// mainBody.rightAnchor.constraint(equalTo: self.rightAnchor).isActive = true
// mainBody.bottomAnchor.constraint(equalTo: self.bottomAnchor).isActive = true
}
required init?(coder decoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - MGLCalloutView API
func presentCallout(from rect: CGRect, in view: UIView, constrainedTo constrainedRect: CGRect, animated: Bool) {
delegate?.calloutViewWillAppear?(self)
view.addSubview(self)
// Prepare title label.
mainBody.setTitle(representedObject.title!, for: .normal)
mainBody.titleLabel?.lineBreakMode = .byWordWrapping
mainBody.titleLabel?.numberOfLines = 0
mainBody.sizeToFit()
if isCalloutTappable() {
// Handle taps and eventually try to send them to the delegate (usually the map view).
mainBody.addTarget(self, action: #selector(CustomCalloutView.calloutTapped), for: .touchUpInside)
} else {
// Disable tapping and highlighting.
mainBody.isUserInteractionEnabled = false
}
// Prepare our frame, adding extra space at the bottom for the tip.
let frameWidth = mainBody.bounds.size.width
let frameHeight = mainBody.bounds.size.height + tipHeight
let frameOriginX = rect.origin.x + (rect.size.width/2.0) - (frameWidth/2.0)
let frameOriginY = rect.origin.y - frameHeight
frame = CGRect(x: frameOriginX, y: frameOriginY, width: frameWidth, height: frameHeight)
if animated {
alpha = 0
UIView.animate(withDuration: 0.2) { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.alpha = 1
strongSelf.delegate?.calloutViewDidAppear?(strongSelf)
}
} else {
delegate?.calloutViewDidAppear?(self)
}
}
func dismissCallout(animated: Bool) {
if (superview != nil) {
if animated {
UIView.animate(withDuration: 0.2, animations: { [weak self] in
self?.alpha = 0
}, completion: { [weak self] _ in
self?.removeFromSuperview()
})
} else {
removeFromSuperview()
}
}
}
// MARK: - Callout interaction handlers
func isCalloutTappable() -> Bool {
if let delegate = delegate {
if delegate.responds(to: #selector(MGLCalloutViewDelegate.calloutViewShouldHighlight)) {
return delegate.calloutViewShouldHighlight!(self)
}
}
return false
}
#objc func calloutTapped() {
if isCalloutTappable() && delegate!.responds(to: #selector(MGLCalloutViewDelegate.calloutViewTapped)) {
delegate!.calloutViewTapped!(self)
}
}
// MARK: - Custom view styling
override func draw(_ rect: CGRect) {
// Draw the pointed tip at the bottom.
let fillColor: UIColor = .white
let tipLeft = rect.origin.x + (rect.size.width / 2.0) - (tipWidth / 2.0)
let tipBottom = CGPoint(x: rect.origin.x + (rect.size.width / 2.0), y: rect.origin.y + rect.size.height)
let heightWithoutTip = rect.size.height - tipHeight - 1
let currentContext = UIGraphicsGetCurrentContext()!
let tipPath = CGMutablePath()
tipPath.move(to: CGPoint(x: tipLeft, y: heightWithoutTip))
tipPath.addLine(to: CGPoint(x: tipBottom.x, y: tipBottom.y))
tipPath.addLine(to: CGPoint(x: tipLeft + tipWidth, y: heightWithoutTip))
tipPath.closeSubpath()
fillColor.setFill()
currentContext.addPath(tipPath)
currentContext.fillPath()
}
}
This is what it looks like for a short title and a long title. When the title gets too long, I want the text to wrap and the bubble to get a taller height. As you can see in the image set below, the first 'Short Name' works fine as a map annotation bubble. When the name gets super long though, it just widens the bubble to the point it goes off the screen.
https://imgur.com/a/I5z0zUd
Any help on how to fix is much appreciated. Thanks!
To enable word-wrapping to multiple lines in a UIButton, you need to create your own button subclass.
For example:
class MultilineTitleButton: UIButton {
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
commonInit()
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
func commonInit() -> Void {
self.titleLabel?.numberOfLines = 0
self.titleLabel?.textAlignment = .center
self.setContentHuggingPriority(UILayoutPriority.defaultLow + 1, for: .vertical)
self.setContentHuggingPriority(UILayoutPriority.defaultLow + 1, for: .horizontal)
}
override var intrinsicContentSize: CGSize {
let size = self.titleLabel!.intrinsicContentSize
return CGSize(width: size.width + contentEdgeInsets.left + contentEdgeInsets.right, height: size.height + contentEdgeInsets.top + contentEdgeInsets.bottom)
}
override func layoutSubviews() {
super.layoutSubviews()
titleLabel?.preferredMaxLayoutWidth = self.titleLabel!.frame.size.width
}
}
That button will wrap the title onto multiple lines, cooperating with auto-layout / constraints.
I don't have any projects with MapBox, but here is an example using a modified version of your CustomCalloutView. I commented out any MapBox specific code. You may be able to un-comment those lines and use this as-is:
class CustomCalloutView: UIView { //}, MGLCalloutView {
//var representedObject: MGLAnnotation
var repTitle: String = ""
// Allow the callout to remain open during panning.
let dismissesAutomatically: Bool = false
let isAnchoredToAnnotation: Bool = true
// https://github.com/mapbox/mapbox-gl-native/issues/9228
// NOTE: this causes a vertical shift when NOT using MapBox
// override var center: CGPoint {
// set {
// var newCenter = newValue
// newCenter.y -= bounds.midY
// super.center = newCenter
// }
// get {
// return super.center
// }
// }
lazy var leftAccessoryView = UIView() /* unused */
lazy var rightAccessoryView = UIView() /* unused */
//weak var delegate: MGLCalloutViewDelegate?
let tipHeight: CGFloat = 10.0
let tipWidth: CGFloat = 20.0
let mainBody: UIButton
var anchorView: UIView!
override func willMove(toSuperview newSuperview: UIView?) {
if newSuperview == nil {
anchorView.removeFromSuperview()
}
}
//required init(representedObject: MGLAnnotation) {
required init(title: String) {
self.repTitle = title
self.mainBody = MultilineTitleButton()
super.init(frame: .zero)
backgroundColor = .clear
mainBody.backgroundColor = .white
mainBody.setTitleColor(.black, for: [])
mainBody.tintColor = .black
mainBody.contentEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 10.0)
mainBody.layer.cornerRadius = 4.0
addSubview(mainBody)
mainBody.translatesAutoresizingMaskIntoConstraints = false
let padding: CGFloat = 8.0
NSLayoutConstraint.activate([
mainBody.topAnchor.constraint(equalTo: self.topAnchor, constant: padding),
mainBody.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: padding),
mainBody.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -padding),
mainBody.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -padding),
])
}
required init?(coder decoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - MGLCalloutView API
func presentCallout(from rect: CGRect, in view: UIView, constrainedTo constrainedRect: CGRect, animated: Bool) {
//delegate?.calloutViewWillAppear?(self)
// since we'll be using auto-layout for the mutli-line button
// we'll add an "anchor view" to the superview
// it will be removed when self is removed
anchorView = UIView(frame: rect)
anchorView.isUserInteractionEnabled = false
anchorView.backgroundColor = .clear
view.addSubview(anchorView)
view.addSubview(self)
// Prepare title label.
//mainBody.setTitle(representedObject.title!, for: .normal)
mainBody.setTitle(self.repTitle, for: .normal)
// if isCalloutTappable() {
// // Handle taps and eventually try to send them to the delegate (usually the map view).
// mainBody.addTarget(self, action: #selector(CustomCalloutView.calloutTapped), for: .touchUpInside)
// } else {
// // Disable tapping and highlighting.
// mainBody.isUserInteractionEnabled = false
// }
self.translatesAutoresizingMaskIntoConstraints = false
anchorView.autoresizingMask = [.flexibleTopMargin, .flexibleLeftMargin, .flexibleRightMargin, .flexibleBottomMargin]
NSLayoutConstraint.activate([
self.centerXAnchor.constraint(equalTo: anchorView.centerXAnchor),
self.bottomAnchor.constraint(equalTo: anchorView.topAnchor),
self.widthAnchor.constraint(lessThanOrEqualToConstant: constrainedRect.width),
])
if animated {
alpha = 0
UIView.animate(withDuration: 0.2) { [weak self] in
guard let strongSelf = self else {
return
}
strongSelf.alpha = 1
//strongSelf.delegate?.calloutViewDidAppear?(strongSelf)
}
} else {
//delegate?.calloutViewDidAppear?(self)
}
}
func dismissCallout(animated: Bool) {
if (superview != nil) {
if animated {
UIView.animate(withDuration: 0.2, animations: { [weak self] in
self?.alpha = 0
}, completion: { [weak self] _ in
self?.removeFromSuperview()
})
} else {
removeFromSuperview()
}
}
}
// MARK: - Callout interaction handlers
// func isCalloutTappable() -> Bool {
// if let delegate = delegate {
// if delegate.responds(to: #selector(MGLCalloutViewDelegate.calloutViewShouldHighlight)) {
// return delegate.calloutViewShouldHighlight!(self)
// }
// }
// return false
// }
//
// #objc func calloutTapped() {
// if isCalloutTappable() && delegate!.responds(to: #selector(MGLCalloutViewDelegate.calloutViewTapped)) {
// delegate!.calloutViewTapped!(self)
// }
// }
// MARK: - Custom view styling
override func draw(_ rect: CGRect) {
print(#function)
// Draw the pointed tip at the bottom.
let fillColor: UIColor = .red
let tipLeft = rect.origin.x + (rect.size.width / 2.0) - (tipWidth / 2.0)
let tipBottom = CGPoint(x: rect.origin.x + (rect.size.width / 2.0), y: rect.origin.y + rect.size.height)
let heightWithoutTip = rect.size.height - tipHeight - 1
let currentContext = UIGraphicsGetCurrentContext()!
let tipPath = CGMutablePath()
tipPath.move(to: CGPoint(x: tipLeft, y: heightWithoutTip))
tipPath.addLine(to: CGPoint(x: tipBottom.x, y: tipBottom.y))
tipPath.addLine(to: CGPoint(x: tipLeft + tipWidth, y: heightWithoutTip))
tipPath.closeSubpath()
fillColor.setFill()
currentContext.addPath(tipPath)
currentContext.fillPath()
}
}
Here is a sample view controller showing that "Callout View" with various length titles, restricted to 70% of the width of the view:
class CalloutTestVC: UIViewController {
let sampleTitles: [String] = [
"Short Title",
"Slightly Longer Title",
"A ridiculously long title that will need to wrap!",
]
var idx: Int = -1
let tapView = UIView()
var ccv: CustomCalloutView!
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(red: 0.8939146399, green: 0.8417750597, blue: 0.7458069921, alpha: 1)
tapView.backgroundColor = .systemBlue
tapView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(tapView)
let g = view.safeAreaLayoutGuide
NSLayoutConstraint.activate([
tapView.centerYAnchor.constraint(equalTo: g.centerYAnchor),
tapView.centerXAnchor.constraint(equalTo: g.centerXAnchor),
tapView.widthAnchor.constraint(equalToConstant: 60),
tapView.heightAnchor.constraint(equalTo: tapView.widthAnchor),
])
// tap the Blue View to cycle through Sample Titles for the Callout View
// using the Blue view as the "anchor rect"
let t = UITapGestureRecognizer(target: self, action: #selector(gotTap))
tapView.addGestureRecognizer(t)
}
#objc func gotTap() -> Void {
if ccv != nil {
ccv.removeFromSuperview()
}
// increment sampleTitles array index
// to cycle through the strings
idx += 1
let validIdx = idx % sampleTitles.count
let str = sampleTitles[validIdx]
// create a new Callout view
ccv = CustomCalloutView(title: str)
// to restrict the "callout view" width to less-than 1/2 the screen width
// use view.width * 0.5 for the constrainedTo width
// may look better restricting it to 70%
ccv.presentCallout(from: tapView.frame, in: self.view, constrainedTo: CGRect(x: 0, y: 0, width: view.frame.size.width * 0.7, height: 100), animated: false)
}
}
It looks like this:
The UIButton class owns the titleLabel and is going to position and set the constraints on that label itself. More likely than not you are going to have to create a subclass of UIButton and override its "updateConstraints" method to position the titleLabel where you want it to go.
Your code should probably not be basing the size of the button off the size of the screen. It might set the size of off some other view in your hierarchy that happens to be the size of the screen but grabbing the screen bounds in the middle of setting a view's size is unusual.
UIKit/Programmatic UI
I have an UIScrollView with an UIImageView inside. The image is set by user selection and can have all kinds of sizes. What I want is that the image initially fills the screen (view) and then can be zoomed and scrolled all the way to the edges of the image.
If I add the ImageView directly to the view (no scrollView), I get it to fill the screen with the following code:
mapImageView.image = ProjectImages.projectDefaultImage
mapImageView.translatesAutoresizingMaskIntoConstraints = false
mapImageView.contentMode = .scaleAspectFill
view.addSubview(mapImageView)
Now the same with the scrollView and the embedded imageView:
view.insertSubview(mapImageScrollView, at: 0)
mapImageScrollView.delegate = self
mapImageScrollView.translatesAutoresizingMaskIntoConstraints = false
mapImageScrollView.contentMode = .scaleAspectFill
mapImageScrollView.maximumZoomScale = 4.0
mapImageScrollView.pinToEdges(of: view, safeArea: true)
mapImageView.image = ProjectImages.projectDefaultImage
mapImageView.translatesAutoresizingMaskIntoConstraints = false
mapImageView.contentMode = .scaleAspectFill
mapImageScrollView.addSubview(mapImageView)
And now, if the image's height is smaller than the view's height, the image does not fill the screen and I'm left with a blank view area below the image. I can zoom and scroll ok, and then the image does fill the view.
Adding contsraints will fill the view as I want, but interferes with the zooming and scrolling and prevents me getting to the edges of the image when zoomed in.
How to set this up correctly ?
You might find this useful...
It allows you to zoom an image in a scrollView, starting with it centered and maintaining aspect ratio.
Here's a complete implementation. It has two important variables at the top:
// can be .scaleAspectFill or .scaleAspectFit
var fitMode: UIView.ContentMode = .scaleAspectFill
// if fitMode is .scaleAspectFit, allowFullImage is ignored
// if fitMode is .scaleAspectFill, image will start zoomed to .scaleAspectFill
// if allowFullImage is false, image will zoom back to .scaleAspectFill if "pinched in"
// if allowFullImage is true, image can be "pinched in" to see the full image
var allowFullImage: Bool = true
Everything is done via code - no #IBOutlet or other connections - so just create add a new view controller and assign its custom class to ZoomAspectViewController (and edit the name of the image you want to use):
class ZoomAspectViewController: UIViewController, UIScrollViewDelegate {
var scrollView: UIScrollView!
var imageView: UIImageView!
var imageViewBottomConstraint: NSLayoutConstraint!
var imageViewLeadingConstraint: NSLayoutConstraint!
var imageViewTopConstraint: NSLayoutConstraint!
var imageViewTrailingConstraint: NSLayoutConstraint!
// can be .scaleAspectFill or .scaleAspectFit
var fitMode: UIView.ContentMode = .scaleAspectFit
// if fitMode is .scaleAspectFit, allowFullImage is ignored
// if fitMode is .scaleAspectFill, image will start zoomed to .scaleAspectFill
// if allowFullImage is false, image will zoom back to .scaleAspectFill if "pinched in"
// if allowFullImage is true, image can be "pinched in" to see the full image
var allowFullImage: Bool = true
override func viewDidLoad() {
super.viewDidLoad()
guard let img = UIImage(named: "myImage") else {
fatalError("Could not load the image!!!")
}
scrollView = UIScrollView()
imageView = UIImageView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.contentMode = .scaleToFill
scrollView.addSubview(imageView)
view.addSubview(scrollView)
// respect safe area
let g = view.safeAreaLayoutGuide
imageViewTopConstraint = imageView.topAnchor.constraint(equalTo: scrollView.topAnchor)
imageViewBottomConstraint = imageView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor)
imageViewLeadingConstraint = imageView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor)
imageViewTrailingConstraint = imageView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor)
NSLayoutConstraint.activate([
scrollView.topAnchor.constraint(equalTo: g.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: g.bottomAnchor),
scrollView.leadingAnchor.constraint(equalTo: g.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: g.trailingAnchor),
imageViewTopConstraint,
imageViewBottomConstraint,
imageViewLeadingConstraint,
imageViewTrailingConstraint,
])
scrollView.delegate = self
scrollView.minimumZoomScale = 0.1
scrollView.maximumZoomScale = 5.0
imageView.image = img
imageView.frame.size = img.size
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
coordinator.animate(alongsideTransition: { _ in
self.updateMinZoomScaleForSize(size, shouldSize: (self.scrollView.zoomScale == self.scrollView.minimumZoomScale))
self.updateConstraintsForSize(size)
}, completion: {
_ in
})
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateMinZoomScaleForSize(scrollView.bounds.size)
updateConstraintsForSize(scrollView.bounds.size)
if fitMode == .scaleAspectFill {
centerImageView()
}
}
func updateMinZoomScaleForSize(_ size: CGSize, shouldSize: Bool = true) {
guard let img = imageView.image else {
return
}
var bShouldSize = shouldSize
let widthScale = size.width / img.size.width
let heightScale = size.height / img.size.height
var minScale = min(widthScale, heightScale)
let startScale = max(widthScale, heightScale)
if fitMode == .scaleAspectFill && !allowFullImage {
minScale = startScale
}
if scrollView.zoomScale < minScale {
bShouldSize = true
}
scrollView.minimumZoomScale = minScale
if bShouldSize {
scrollView.zoomScale = fitMode == .scaleAspectFill ? startScale : minScale
}
}
func scrollViewDidZoom(_ scrollView: UIScrollView) {
updateConstraintsForSize(scrollView.bounds.size)
}
func centerImageView() -> Void {
let yOffset = (scrollView.frame.size.height - imageView.frame.size.height) / 2
let xOffset = (scrollView.frame.size.width - imageView.frame.size.width) / 2
scrollView.contentOffset = CGPoint(x: -xOffset, y: -yOffset)
}
func updateConstraintsForSize(_ size: CGSize) {
let yOffset = max(0, (size.height - imageView.frame.height) / 2)
imageViewTopConstraint.constant = yOffset
imageViewBottomConstraint.constant = yOffset
let xOffset = max(0, (size.width - imageView.frame.width) / 2)
imageViewLeadingConstraint.constant = xOffset
imageViewTrailingConstraint.constant = xOffset
view.layoutIfNeeded()
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
}
Edit
As an example, I used this image (2560 x 1440):
and I get this result on launch:
and maximum zoom in (5.0) scrolled to top-center:
Edit 2
Same image, at launch, with:
var fitMode: UIView.ContentMode = .scaleAspectFill
instead of .scaleAspectFit:
I've found this solution that works for me, when setting and changing the image, I calculate the minimum needed zoom scale and set it on the scrollView:
var selectedMapImage: MapImage? {
didSet {
mapImageView.image = mapImagesController.getImageForMapImage(selectedMapImage!)
mapImageScrollView.minimumZoomScale = view.bounds.height / mapImageView.image!.size.height
mapImageScrollView.setZoomScale(mapImageScrollView.minimumZoomScale, animated: true)
mapImageScrollView.scrollRectToVisible(view.bounds, animated: true)
}
}
I am using UIStackView and adding three buttons to it. I want it so that the button with the most text (B1) will be auto resized to fit the width and the other buttons will share the same font size as B1.
#IBOutlet weak var stackView: UIStackView!
var btnTitles = [String]()
btnTitles.append("Practice Exams")
btnTitles.append("Test Taking Tips")
btnTitles.append("About")
createButtons(buttonTitles: btnTitles)
var min = CGFloat(Int.max) // keep track of min font
func createButtons(buttonTitles: [String]) {
var Buttons = [UIButton]()
for title in buttonTitles {
let button = makeButtonWithText(text: title)
// set the font to dynamically size
button.titleLabel!.numberOfLines = 1
button.titleLabel!.adjustsFontSizeToFitWidth = true
button.titleLabel!.baselineAdjustment = .alignCenters // I think it keeps it centered vertically
button.contentEdgeInsets = UIEdgeInsetsMake(5, 10, 5, 10); // set margins
if (button.titleLabel?.font.pointSize)! < min {
min = (button.titleLabel?.font.pointSize)! // to get the minimum font size of any of the buttons
}
stackView.addArrangedSubview(button)
Buttons.append(button)
}
}
func makeButtonWithText(text:String) -> UIButton {
var myButton = UIButton(type: UIButtonType.system)
//Set a frame for the button. Ignored in AutoLayout/ Stack Views
myButton.frame = CGRect(x: 30, y: 30, width: 150, height: 100)
// background color - light blue
myButton.backgroundColor = UIColor(red: 0.255, green: 0.561, blue: 0.847, alpha: 1)
//State dependent properties title and title color
myButton.setTitle(text, for: UIControlState.normal)
myButton.setTitleColor(UIColor.white, for: UIControlState.normal)
// set the font to dynamically size
myButton.titleLabel!.font = myButton.titleLabel!.font.withSize(70)
myButton.contentHorizontalAlignment = .center // align center
return myButton
}
I wanted to find the minimum font size and then set all the buttons to the minimum in viewDidAppear button the font prints as 70 for all of them even though they clearly appear different sizes (see image)
override func viewDidAppear(_ animated: Bool) {
print("viewDidAppear")
let button = stackView.arrangedSubviews[0] as! UIButton
print(button.titleLabel?.font.pointSize)
let button1 = stackView.arrangedSubviews[1] as! UIButton
print(button1.titleLabel?.font.pointSize)
let button2 = stackView.arrangedSubviews[2] as! UIButton
print(button2.titleLabel?.font.pointSize)
}
image
You can try playing around with this func to return the scaled-font-size of a label:
func actualFontSize(for aLabel: UILabel) -> CGFloat {
// label must have text, must have .minimumScaleFactor and must have .adjustsFontSizeToFitWidth == true
guard let str = aLabel.text,
aLabel.minimumScaleFactor > 0.0,
aLabel.adjustsFontSizeToFitWidth
else { return aLabel.font.pointSize }
let attributes = [NSAttributedString.Key.font : aLabel.font]
let attStr = NSMutableAttributedString(string:str, attributes:attributes as [NSAttributedString.Key : Any])
let context = NSStringDrawingContext()
context.minimumScaleFactor = aLabel.minimumScaleFactor
_ = attStr.boundingRect(with: aLabel.bounds.size, options: .usesLineFragmentOrigin, context: context)
return aLabel.font.pointSize * context.actualScaleFactor
}
On viewDidAppear() you would loop through the buttons, getting the smallest actual font size, then set the font size for each button to that value.
It will take some experimentation... For one thing, I've noticed in the past that font-sizes can get rounded - so setting a label's font point size to 20.123456789 won't necessarily give you that exact point size. Also, since this changes the actual font size assigned to the labels, you'll need to do some resetting if you change the button title dynamically. Probably also need to account for button frame changes (such as with device rotation, etc).
But... here is a quick test that you can run to see the approach:
class TestViewController: UIViewController {
let stackView: UIStackView = {
let v = UIStackView()
v.translatesAutoresizingMaskIntoConstraints = false
v.axis = .vertical
v.alignment = .center
v.distribution = .fillEqually
v.spacing = 8
return v
}()
var btnTitles = [String]()
var theButtons = [UIButton]()
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
fixButtonFonts()
}
func setupUI() -> Void {
view.addSubview(stackView)
NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 40),
stackView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 40),
stackView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -40),
])
btnTitles.append("Practice Exams")
btnTitles.append("Test Taking Tips")
btnTitles.append("About")
createButtons(buttonTitles: btnTitles)
}
func fixButtonFonts() -> Void {
var minActual = CGFloat(70)
// get the smallest actual font size
theButtons.forEach { btn in
if let lbl = btn.titleLabel {
let act = actualFontSize(for: lbl)
// for debugging
//print("actual font size: \(act)")
minActual = Swift.min(minActual, act)
}
}
// set font size for each button
theButtons.forEach { btn in
if let lbl = btn.titleLabel {
lbl.font = lbl.font.withSize(minActual)
}
}
}
func createButtons(buttonTitles: [String]) {
for title in buttonTitles {
let button = makeButtonWithText(text: title)
// set the font to dynamically size
button.titleLabel!.numberOfLines = 1
button.titleLabel!.adjustsFontSizeToFitWidth = true
// .minimumScaleFactor is required
button.titleLabel!.minimumScaleFactor = 0.05
button.titleLabel!.baselineAdjustment = .alignCenters // I think it keeps it centered vertically
button.contentEdgeInsets = UIEdgeInsets(top: 5, left: 10, bottom: 5, right: 10); // set margins
stackView.addArrangedSubview(button)
theButtons.append(button)
}
}
func makeButtonWithText(text:String) -> UIButton {
let myButton = UIButton(type: UIButton.ButtonType.system)
//Set a frame for the button. Ignored in AutoLayout/ Stack Views
myButton.frame = CGRect(x: 30, y: 30, width: 150, height: 100)
// background color - light blue
myButton.backgroundColor = UIColor(red: 0.255, green: 0.561, blue: 0.847, alpha: 1)
//State dependent properties title and title color
myButton.setTitle(text, for: UIControl.State.normal)
myButton.setTitleColor(UIColor.white, for: UIControl.State.normal)
// set the font to dynamically size
myButton.titleLabel!.font = myButton.titleLabel!.font.withSize(70)
myButton.contentHorizontalAlignment = .center // align center
return myButton
}
func actualFontSize(for aLabel: UILabel) -> CGFloat {
// label must have text, must have .minimumScaleFactor and must have .adjustsFontSizeToFitWidth == true
guard let str = aLabel.text,
aLabel.minimumScaleFactor > 0.0,
aLabel.adjustsFontSizeToFitWidth
else { return aLabel.font.pointSize }
let attributes = [NSAttributedString.Key.font : aLabel.font]
let attStr = NSMutableAttributedString(string:str, attributes:attributes as [NSAttributedString.Key : Any])
let context = NSStringDrawingContext()
context.minimumScaleFactor = aLabel.minimumScaleFactor
_ = attStr.boundingRect(with: aLabel.bounds.size, options: .usesLineFragmentOrigin, context: context)
return aLabel.font.pointSize * context.actualScaleFactor
}
}
Result:
when creating an UIImageView with an Image, the view's frame is set to the image's original size. I would like to know it's actual size after all anchor constraints have been applied
I've tried different images and they all do the same thing.
The class with the issues is: SuggestionCloud;
I will explain the issue in three parts:
FIRST: the superclass that sets up all UI elements and invokes the "faulty' custom UIImage class (SuggestionCloud).
SECOND: The suggesionCloud Class
UIScaleControllerVew
Class UIScaleControllerView: UIViewController: {
let suggestionCloud : SuggenstionCloud = {
let cloud = SuggenstionCloud(image: UIImage(named: "suggestionCloud.png"))
cloud.translatesAutoresizingMaskIntoConstraints = false;
return cloud;
}();
override func viewDidLoad() {
super.viewDidLoad()
print("UIScaleController_DidLoad")
view.backgroundColor = UIColor(hexString: "8ED7F5")
view.addSubview(weigtImageView)
view.addSubview(textView)
view.addSubview(bottomMenu);
view.addSubview(suggestionCloud)
setUpLayout()
suggestionCloud.setLabels(weightedTags: stuff, selectedTags: selected)
}
extension UIScaleControllerVew {
func setUpLayout() {
// SuggestionCloud
suggestionCloud.setConstraints(
topAnchor: textView.bottomAnchor, topConstant: 0,
bottomAnchor: bottomMenu.topAnchor, bottomConstant: 0,
trailingAnchor: view.trailingAnchor, trailingConstant: 10,
leadingAnchor: view.leadingAnchor, leadingConstant: 10
//all UI elements are setup underneath..took those out for th
The suggestionCloud Class:
import UIKit
class SuggenstionCloud: UIImageView {
override init(image: UIImage?) {
super.init(image: image)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setConstraints(
topAnchor : NSLayoutAnchor<NSLayoutYAxisAnchor>, topConstant: CGFloat,
bottomAnchor: NSLayoutAnchor<NSLayoutYAxisAnchor>, bottomConstant: CGFloat,
trailingAnchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, trailingConstant: CGFloat,
leadingAnchor: NSLayoutAnchor<NSLayoutXAxisAnchor>, leadingConstant: CGFloat)
{
self.contentMode = .scaleToFill
self.topAnchor.constraint(equalTo: topAnchor, constant: topConstant).isActive = true;
self.bottomAnchor.constraint(equalTo: bottomAnchor, constant: bottomConstant).isActive = true;
self.trailingAnchor.constraint(equalTo: trailingAnchor, constant: trailingConstant).isActive = true;
self.leadingAnchor.constraint(equalTo: leadingAnchor, constant: leadingConstant).isActive = true;
}
public func setLabels(weightedTags: [String: Int], selectedTags: [String]) {
let buttons : [UIButton] = createButtons(weightedTags: weightedTags);
createLayout(buttons: buttons)
}
private func createButton(buttonText: String) -> UIButton {
let button = UIButton()
button.setTitle(buttonText, for: .normal)
button.titleLabel?.font = UIFont(name: "Avenir-Light", size: 20.0)
button.translatesAutoresizingMaskIntoConstraints = false;
button.backgroundColor = .blue
self.addSubview(button)
return button;
}
private func createButtons(weightedTags: [String: Int]) -> [UIButton] {
var buttons : [UIButton] = [];
for tag in weightedTags {
buttons.append(createButton(buttonText: tag.key))
}
return buttons;
}
private func createLayout(buttons : [UIButton]) {
if buttons.count == 0 { return }
let leftEdgePadding : CGFloat = 30;
let rightEdgePadding : CGFloat = 30;
let topPadding : CGFloat = 30;
let padding : CGFloat = 10;
let availableHeight : CGFloat = self.frame.height + (-2 * topPadding)
let availableWidth : CGFloat = self.frame.width + (-2 * padding)
var i = 0;
var totalHeight : CGFloat = 0;
var rowLength : CGFloat = 0;
var rowCount : Int = 0;
var lastButton : UIButton!
for button in buttons {
if totalHeight > availableHeight {print("Cloud out of space"); return}
if rowLength == 0 && rowCount == 0
{
setFirstConstraints(button: button, padding: topPadding)
totalHeight = button.intrinsicContentSize.height + topPadding;
rowLength += button.intrinsicContentSize.width + padding
lastButton = button;
}
else if rowLength + button.intrinsicContentSize.width < availableWidth
{
setConstraints(button, padding, topPadding, lastButton, rowCount)
rowLength += button.intrinsicContentSize.width + padding;
lastButton = button;
}
else
{
totalHeight += button.intrinsicContentSize.height + padding
setNewRowConstraint(button: button, padding: padding, totalHeight: totalHeight)
rowLength = 0;
rowCount += 1
lastButton = button
}
i += 1;
}
}
private func setNewRowConstraint(
button: UIButton,
padding: CGFloat,
totalHeight: CGFloat)
{
let totalPadding = button.intrinsicContentSize.height + padding + totalHeight
button.topAnchor.constraint(equalTo: self.topAnchor, constant: totalHeight).isActive = true
button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: padding).isActive = true
}
private func setConstraints (
_ button : UIButton,
_ leftPadding: CGFloat,
_ topPadding: CGFloat ,
_ lastButton: UIButton,
_ rows: Int)
{
button.leadingAnchor.constraint(equalTo: lastButton.trailingAnchor, constant: leftPadding).isActive = true
button.topAnchor.constraint(equalTo: self.topAnchor, constant: topPadding).isActive = true
}
private func setFirstConstraints(button: UIButton, padding: CGFloat)
{
button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: padding).isActive = true
button.topAnchor.constraint(equalTo: self.topAnchor, constant: padding ).isActive = true
}
}
THIRD: finally the issue:
I'm creating buttons dynamically to fit inside the view.
I have to set each buttion's anchors programmatically to fit their supeclass's . dimensions dynamically.
However: Inside my algorithm self.frame.size is : 800,800 : the original image size upon init().
let availableHeight : CGFloat = self.frame.height // = 800
let availableWidth : CGFloat = self.frame.width // 800 no bueno
The weird this is that the actual size of the UIView is correct in the Simulator. So the contraints work, but the Image view is not aware of it's actual dimensions
Could anyone help me figure this one out? What am i doing wrong?
Set labels is being called too soon. The frame isn't set by the time viewDidLoad is called. You need to wait until after viewDidLayoutSubviews/layoutSubviews. Using a flag and doing the set up in didLayoutSubviews should allow you to read the frame properly.
var didSetUpSuggestionCloud = false
override func viewDidLoad() {
super.viewDidLoad()
print("UIScaleController_DidLoad")
view.backgroundColor = UIColor(hexString: "8ED7F5")
view.addSubview(weigtImageView)
view.addSubview(textView)
view.addSubview(bottomMenu);
view.addSubview(suggestionCloud)
setUpLayout()
//make sure suggestionClouds setConstraints is called here
}
override func viewDidLayoutSubviews() {
guard !self.didSetUpSuggestionCloud else {
return
}
suggestionCloud.setLabels(weightedTags: stuff, selectedTags: selected)
self.didSetUpSuggestionCloud = true
}
A flag is necessary because viewDidLayoutSubviews can be called multiple times throughout a view controllers lifecycle.
I'm new to working with ScrollViews, and I'm doing it all programatically. I must be missing something super simple, but when I have no subviews, the scrollview shows up properly and scrolls up and down. But any time I add any subviews, the whole thing refuses to show up at all.
class DetailedPostScrollView: UIScrollView {
let topLabel: UILabel = {
let label = UILabel()
label.text = "this is the top"
return label
}()
let bottomLabel: UILabel = {
let label = UILabel()
label.text = "this is the bottom"
return label
}()
override init(frame: CGRect) {
super.init(frame: frame)
contentSize = CGSize(width: frame.width, height: 2000)
alwaysBounceVertical = true
addSubviewUsingAutoLayout(topLabel, bottomLabel)
topLabel.centerXAnchor.constrain(to: self.centerXAnchor)
topLabel.widthAnchor.constrain(to: 200)
topLabel.heightAnchor.constrain(to: 50)
topLabel.topAnchor.constrain(to: self.topAnchor, with: 100)
bottomLabel.centerXAnchor.constrain(to: self.centerXAnchor)
bottomLabel.widthAnchor.constrain(to: 200)
bottomLabel.heightAnchor.constrain(to: 50)
bottomLabel.bottomAnchor.constrain(to: self.bottomAnchor)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
And in my viewController, I instantiate and add the scroll view
let detailedPostScrollView = DetailedPostScrollView(frame: UIScreen.main.bounds)
detailedPostScrollView.backgroundColor = UIColor.purple
view.addSubviewUsingAutoLayout(detailedPostScrollView)
Again, I"m sure it's something super simple but I checked out all the questions and tutorial videos and couldn't see where I"m going wrong. Thanks for your help.
Edit: It seems to work fine when I do it all programmatically from a viewcontroller, as follows :
let scrollView: UIScrollView = {
let sv = UIScrollView()
sv.contentSize = CGSize(width: view.frame.width, height: 2000)
sv.backgroundColor = UIColor.purple
return sv
}()
view.addSubviewUsingAutoLayout(scrollView)
scrollView.topAnchor.constrain(to: view.topAnchor, with: 100)
scrollView.leadingAnchor.constrain(to: view.leadingAnchor)
scrollView.trailingAnchor.constrain(to: view.trailingAnchor)
scrollView.bottomAnchor.constrain(to: view.bottomAnchor)
let topLabel: UILabel = {
let label = UILabel()
label.text = "this is the top"
return label
}()
scrollView.addSubviewUsingAutoLayout(topLabel)
topLabel.centerXAnchor.constrain(to: scrollView.centerXAnchor)
topLabel.widthAnchor.constrain(to: 200)
topLabel.heightAnchor.constrain(to: 50)
topLabel.topAnchor.constrain(to: scrollView.topAnchor)
Something is off when I create a custom scrollview and instantiate that in my vc.
Try this :
class ViewController : UIViewController , UIScrollViewDelegate{
var scrollView : UIScrollView! ;
var containerView = UIView();
var contentSize : CGSize {
get {
return CGSize(width: view.frame.width, height: 2000);
}
}
override func viewDidLoad() {
super.viewDidLoad();
setupScrollView();
setupItems();
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews();
self.scrollView.frame = self.view.bounds;
containerView.frame = CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height);
}
private func setupScrollView() {
scrollView = UIScrollView();
scrollView.delegate = self;
scrollView.backgroundColor = .white;
scrollView.contentSize = contentSize;
self.view.addSubview(scrollView);
containerView.backgroundColor = .purple;
self.scrollView.addSubview(containerView);
}
private func setupItems() {
let topButton = UIButton();
topButton.translatesAutoresizingMaskIntoConstraints = false;
topButton.setTitle("TOP BUTTON", for: .normal);
topButton.setTitleColor(.white, for: .normal);
self.containerView.addSubview(topButton);
let bottomTextField = UITextField();
bottomTextField.translatesAutoresizingMaskIntoConstraints = false;
bottomTextField.borderStyle = .roundedRect;
bottomTextField.placeholder = "BOTTOM TEXT FIELD";
self.containerView.addSubview(bottomTextField);
topButton.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true;
topButton.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 50).isActive = true;
topButton.widthAnchor.constraint(equalToConstant: 100).isActive = true;
topButton.heightAnchor.constraint(equalToConstant: 50).isActive = true;
bottomTextField.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).isActive = true;
bottomTextField.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -50).isActive = true;
bottomTextField.widthAnchor.constraint(equalToConstant: 200).isActive = true;
bottomTextField.heightAnchor.constraint(equalToConstant: 40).isActive = true;
}
}
Set the scroll view frame to view bound in viewDidLayoutViews instead of using the layout , and add a container view as a subview for scroll view and set the size to same content size for scroll view again in viewDidLayoutViews so when ever user rotate the phone he get the correct width , and finally add all your views to container view .