I have a problem in my navigation where my buttons are not displayed correctly. I want 2 rows of 3 buttons each. Each button has an image and a label. The label should be centered and below the image. But that's the tricky thing i can't seem to get working correctly because every time I start the emulator the first button looks okay and all the others are displayed correctly in therms of the image but not the label. I tried to mess with the size of the buttons but noting seems to be working.
I also tried to use the code from that question on stackoverflow but i didn't work out correctly. because the image wasn't displayed at all but the sizing was correct. Furthermore the label was only shown on the first button.
I'm using a IBDesignable to extend the UIButton class which I'm assigning to the Buttons
import UIKit
#IBDesignable
class VerticalButton: UIButton {
#IBInspectable public var padding: CGFloat = 20.0 {
didSet {
setNeedsLayout()
}
}
override var intrinsicContentSize: CGSize {
let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
if let titleSize = titleLabel?.sizeThatFits(maxSize), let imageSize = imageView?.sizeThatFits(maxSize) {
let width = ceil(max(imageSize.width, titleSize.width))
let height = ceil(imageSize.height + titleSize.height + padding)
return CGSize(width: width, height: height)
}
return super.intrinsicContentSize
}
override func layoutSubviews() {
if let image = imageView?.image, let title = titleLabel?.attributedText {
let imageSize = image.size
let titleSize = title.size()
titleEdgeInsets = UIEdgeInsets(top: 0.0, left: -imageSize.width, bottom: -(imageSize.height + padding), right: 0.0)
imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + padding), left: 0.0, bottom: 0.0, right: -titleSize.width)
}
super.layoutSubviews()
}
}
I'm guessing I made an error while calculating in the intrinsicContentSizeof the button.
I also double checked if the class was assigned to all the relevant button and that each button had a label. Beside that I also checked that there were no insets set via the interface builder.
So far I had no luck in finding a solution hence my question. I appreciate your help. If you need more information please don't hesitate to reach out. I will be happy to provide it.
Thanks
Thanks to #DonMag help I've found out that the issue was not with the code instead with the size of the images.
The objective
I've made my own UITextField subclass, to design it in such a way that there is no border, only a white line under the text. (Subclass code below)
The issue
Unfortunately, the white line does not go to the end of the text field (in terms of width):
I've constrained the text field to have 0 px distance to both the left and right edges of the view, but as the images below show, the line does not meet those constraints.
My investigation and hypotheses
I've put in quite some time to debug, the only thing I can think of is that the dimensions specified in the code (copied below) are exécute before the constraints are applied to the view, which is not something I know how to resolve.
The code
//In a UITextField subclass
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
setup()
}
public override func awakeFromNib() {
super.awakeFromNib()
setup()
}
func setup() {
self.delegate = self
// BORDER
// Remove rectangular border
self.borderStyle = .none
self.layer.borderColor = UIColor(red: 1, green: 1, blue: 1, alpha: 0).cgColor //Make transparent
self.layer.borderWidth = 0 // width = 0
// Add bottom border
bottomBorder = UIView(frame: CGRect(x: 0, y: self.frame.height, width: self.frame.width, height: 1))
bottomBorder.backgroundColor = UIColor.white
self.addSubview(bottomBorder)
}
the only thing I can think of is that the dimensions specified in the code (copied below) are exécute before the constraints are applied to the view,
Perfectly correct! The whole problem is that you are hard-coding the frame of the bar based on the frame of the text field. But the frame of the text field is not yet known during init.
Your entire approach here is wrong. You should be using autolayout constraints to constrain the bar to the text field, not giving the bar a fixed frame. That way, no matter what happens to the size of the text field (as it obeys the constraints you have given it), the size of the bar will follow along in perfect harmony.
You need
override func layoutSubviews() {
super.layoutSubviews()
bottomBorder.frame = CGRect(x: 0, y: self.frame.height - 1, width: self.frame.width, height: 1)
}
As correct frame size of superView is captured inside layoutSubviews not in init / awakeFromNib where the frame is set for current development device dimensions if it's in IB
I m very new to IOS development and AutoLayout .
I am facing issues to align the Image and Text inside UIbutton using Storyboard. I had tried to achieve it with TitleEdgeinset and ImageEdge insets accordingly to place the Title ( text ) vertically centered below the Image. But the issue is I have 3 similar buttons which are Vertically stacked ( StackView) and the Text is dynamically set since we have localized strings ( includes Arabic rtl ) .
The image and text moves according to the text length. Is there any ways that I can achieve to make all the buttons with image and text vertically alligned.Also, different screen resolutions are not currently working if using edge insets. Appreciate your help . Thanks in advance.
Few days ago, I solved similar problem,try this
private func adjustImageAndTitleOffsetsForButton (button: UIButton) {
let spacing: CGFloat = 6.0
let imageSize = button.imageView!.frame.size
button.titleEdgeInsets = UIEdgeInsetsMake(0, -imageSize.width, -(imageSize.height + spacing), 0)
let titleSize = button.titleLabel!.frame.size
button.imageEdgeInsets = UIEdgeInsetsMake(-(titleSize.height + spacing), 0, 0, -titleSize.width)
}
call this method for each button, like
self.adjustImageAndTitleOffsetsForButton(yourButton)
Combining Ajay's and Matej's answer:
import Foundation
import UIKit
class VerticalButton: UIButton {
override func awakeFromNib() {
super.awakeFromNib()
self.contentHorizontalAlignment = .left
}
override func layoutSubviews() {
super.layoutSubviews()
centerButtonImageAndTitle()
}
private func centerButtonImageAndTitle() {
let titleSize = self.titleLabel?.frame.size ?? .zero
let imageSize = self.imageView?.frame.size ?? .zero
let spacing: CGFloat = 6.0
self.imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + spacing),left: 0, bottom: 0, right: -titleSize.width)
self.titleEdgeInsets = UIEdgeInsets(top: 0, left: -imageSize.width, bottom: -(imageSize.height + spacing), right: 0)
}
}
I've modified Ajay's answer because my images weren't centered:
func centerButtonImageAndTitle(button: UIButton) {
let spacing: CGFloat = 5
let titleSize = button.titleLabel!.frame.size
let imageSize = button.imageView!.frame.size
button.titleEdgeInsets = UIEdgeInsets(top: 0, left: -imageSize.width, bottom: -(imageSize.height + spacing), right: 0)
button.imageEdgeInsets = UIEdgeInsets(top: -(titleSize.height + spacing), left: -imageSize.width/2, bottom: 0, right: -titleSize.width)
}
This is a solution that worked for me. Just set the button class to HorizontallyCenteredButton in storyboard and set the title and image top and bottom insets according to your needs (to place the image higher than title) and the button will adjust horizontal insets automatically so that the image is centered above title.
class HorizontallyCenteredButton: LocalizedButton {
override func awakeFromNib() {
super.awakeFromNib()
self.contentHorizontalAlignment = .left
}
override func layoutSubviews() {
super.layoutSubviews()
self.centerButtonImageAndTitle()
}
func centerButtonImageAndTitle() {
let size = self.bounds.size
let titleSize = self.titleLabel!.frame.size
let imageSize = self.imageView!.frame.size
self.imageEdgeInsets = UIEdgeInsets(top: self.imageEdgeInsets.top, left: size.width/2 - imageSize.width/2, bottom: self.imageEdgeInsets.bottom, right: 0)
self.titleEdgeInsets = UIEdgeInsets(top: self.titleEdgeInsets.top, left: -imageSize.width + size.width/2 - titleSize.width/2, bottom: self.titleEdgeInsets.bottom, right: 0)
}
}
To align all the buttons vertically, first select the buttons, then click the button on the bottom-right of the storyboard titled "Align", and finally select "Vertical Centers" in the menu that appears. That should do the trick.
I created a view, then put a VStack with image and text, as well as button.
Make sure the VStack constraints are 0 on all 4 sides so it covers the button :)
this is an easy one, so apologies if you have already tried it!
you don't need to set anything for insets, just make sure that the 3 buttons are aligned with horizontal centres ...
select the controls you want to align, and click on the button highlighted below (bottom right on the xCode screen) and select the Vertical Centres option to align the three with each other, or select Horizontally in Container to put them in the middle of your view.
I use UIButton with auto layout. When images are small the tap area is also small. I could imagine several approaches to fix this:
increase the image size, i.e., place a transparent area around the image. This is not good because when you position the image you have to keep the extra transparent border in mind.
use CGRectInset and increase the size. This does not work well with auto layout because using auto layout it will fall back to the original image size.
Beside the two approaches above is there a better solution to increase the tap area of a UIButton?
You can simply adjust the content inset of the button to get your desired size. In code, it will look like this:
button.contentEdgeInsets = UIEdgeInsets(top: 12, left: 16, bottom: 12, right: 16)
//Or if you specifically want to adjust around the image, instead use button.imageEdgeInsets
In interface builder, it will look like this:
Very easy. Create a custom UIButton class. Then override pointInside... method and change the value as you want.
#import "CustomButton.h"
#implementation CustomButton
-(BOOL) pointInside:(CGPoint)point withEvent:(UIEvent *)event
{
CGRect newArea = CGRectMake(self.bounds.origin.x - 10, self.bounds.origin.y - 10, self.bounds.size.width + 20, self.bounds.size.height + 20);
return CGRectContainsPoint(newArea, point);
}
#end
It will take more 10 points touch area for every side.
And Swift 5 version:
class CustomButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return bounds.insetBy(dx: -10, dy: -10).contains(point)
}
}
I confirm that Syed's solution works well even with autolayout. Here's the Swift 4.x version:
import UIKit
class BeepSmallButton: UIButton {
// MARK: - Functions
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let newArea = CGRect(
x: self.bounds.origin.x - 5.0,
y: self.bounds.origin.y - 5.0,
width: self.bounds.size.width + 10.0,
height: self.bounds.size.height + 20.0
)
return newArea.contains(point)
}
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
You can set the button EdgeInsets in storyboard or via code. The size of button should be bigger in height and width than image set to button.
Note: After Xcode8, setting content inset is available in size inspecor
Or you can also use image view with tap gesture on it for action while taping on image view. Make sure to tick User Interaction Enabled for imageview on storyboard for gesture to work.
Make image view bigger than image to set on it and set image on it.
Now set the mode of image view image to center on storyboard/interface builder.
You can tap on image to do action.
Hope it will be helpful.
This should work
import UIKit
#IBDesignable
class GRCustomButton: UIButton {
#IBInspectable var margin:CGFloat = 20.0
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
//increase touch area for control in all directions by 20
let area = self.bounds.insetBy(dx: -margin, dy: -margin)
return area.contains(point)
}
}
Swift 5 version based on Syed's answer (negative values for a larger area):
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return bounds.insetBy(dx: -10, dy: -10).contains(point)
}
Alternatively:
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return bounds.inset(by: UIEdgeInsets(top: -5, left: -5, bottom: -5, right: -5)).contains(point)
}
Some context about the edge insets answer.
When using auto layout combined with content edge insets you may need to change your constraints.
Say you have a 10x10 image and you want to make it 30x30 for a larger hit area:
Set your auto layout constraints to the desired larger area. If you
build right now this would stretch the image.
Using the content edge insets to shrink the space available to the
image so it matches the correct size. In this Example that would 10
10 10 10. Leaving the image with a 10x10 space to draw itself in.
Win.
Both solutions presented here do work ... under the right circumstances it is. But here are some gotchas you might run into.
First something not completely obvious:
tapping has to be WITHIN the button, touching the button bounds slightly does NOT work. If a button is very small, there is a good chance most of your finger will be outside of the button and the tap won't work.
Specific to the solutions above:
SOLUTION 1 #Travis:
Use contentEdgeInsets to increase the button size without increasing the icon/text size, similar to adding padding
button.contentEdgeInsets = UIEdgeInsets(top: 20, left: 20, bottom: 20, right: 20)
This one is straight forward, increasing the button size increases the tap area.
if you have set a height/width frame or constraint, obviously this doesn't do much, and will just distort or shift your icon/text around.
the button size will be bigger. This has to be considered when laying out other views. (offset other views as necessary)
SOLUTION 2 #Syed Sadrul Ullah Sahad:
Subclass UIButton and override point(inside point: CGPoint, with event: UIEvent?) -> Bool
class BigAreaButton: UIButton {
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return bounds.insetBy(dx: -20, dy: -20).contains(point)
}
}
This solution is great because it will allow you extend the tap area beyond the views bounds without changing the layout, but here are the catches:
a parent view needs to have a background, putting a button into an otherwise empty ViewController without a background won't work.
if the button is NESTED, all views up the view hierarchy need to either provide enough "space" or override point-in as well.
e.g.
---------
| |
|oooo |
|oXXo |
|oXXo |
|oooo | Button-X nested in View-o will NOT extend beyond View-o
---------
The way I'd approach this is to give the button some extra room around a small image using contentEdgeInsets (which act like a margin outside the button content), but also override the alignmentRect property with the same insets, which bring the rect that autolayout uses back in to the image. This ensures that autolayout calculates its constraints using the smaller image, rather than the full tappable extent of the button.
class HIGTargetButton: UIButton {
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setImage(_ image: UIImage?, for state: UIControl.State) {
super.setImage(image, for: state)
guard let image = image else { return }
let verticalMarginToAdd = max(0, (targetSize.height - image.size.height) / 2)
let horizontalMarginToAdd = max(0, (targetSize.width - image.size.width) / 2)
let insets = UIEdgeInsets(top: verticalMarginToAdd,
left: horizontalMarginToAdd,
bottom: verticalMarginToAdd,
right: horizontalMarginToAdd)
contentEdgeInsets = insets
}
override var alignmentRectInsets: UIEdgeInsets {
contentEdgeInsets
}
private let targetSize = CGSize(width: 44.0, height: 44.0)
}
The pink button has a bigger tappable target (shown pink here, but could be .clear) and a smaller image - its leading edge is aligned with the green view's leading edge based on the icon, not the whole button.
Subclass UIButton and add this function
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
let verticalInset = CGFloat(10)
let horizontalInset = CGFloat(10)
let largerArea = CGRect(
x: self.bounds.origin.x - horizontalInset,
y: self.bounds.origin.y - verticalInset,
width: self.bounds.size.width + horizontalInset*2,
height: self.bounds.size.height + verticalInset*2
)
return largerArea.contains(point)
}
Swift 4 • Xcode 9
You can select programmatically as -
For Image -
button.imageEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
For Title -
button.titleEdgeInsets = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
An alternative to subclassing would be extending UIControl, adding a touchAreaInsets property to it - by leveraging the objC runtime - and swizzling pointInside:withEvent.
#import <objc/runtime.h>
#import <UIKit/UIKit.h>
#import "NSObject+Swizzling.h" // This is where the magic happens :)
#implementation UIControl (Extensions)
#dynamic touchAreaInsets;
static void * CHFLExtendedTouchAreaControlKey;
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self swizzleSelector:#selector(pointInside:withEvent:) withSelector:#selector(chfl_pointInside:event:) classMethod:NO];
});
}
- (BOOL)chfl_pointInside:(CGPoint)point event:(UIEvent *)event
{
if(UIEdgeInsetsEqualToEdgeInsets(self.touchAreaInsets, UIEdgeInsetsZero)) {
return [self chfl_pointInside:point event:event];
}
CGRect relativeFrame = self.bounds;
CGRect hitFrame = UIEdgeInsetsInsetRect(relativeFrame, self.touchAreaInsets);
return CGRectContainsPoint(hitFrame, point);
}
- (UIEdgeInsets)touchAreaInsets
{
NSValue *value = objc_getAssociatedObject(self, &CHFLExtendedTouchAreaControlKey);
if (value) {
UIEdgeInsets touchAreaInsets; [value getValue:&touchAreaInsets]; return touchAreaInsets;
}
else {
return UIEdgeInsetsZero;
}
}
- (void)setTouchAreaInsets:(UIEdgeInsets)touchAreaInsets
{
NSValue *value = [NSValue value:&touchAreaInsets withObjCType:#encode(UIEdgeInsets)];
objc_setAssociatedObject(self, &CHFLExtendedTouchAreaControlKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
#end
Here is NSObject+Swizzling.h
https://gist.github.com/epacces/fb9b8e996115b3bfa735707810f41ec8
Here is a quite generic interface that allows you to reduce/increase the touch area of UIControls.
#import <UIKit/UIKit.h>
/**
* Extends or reduce the touch area of any UIControls
*
* Example (extends the button's touch area by 20 pt):
*
* UIButton *button = [[UIButton alloc] initWithFrame:CGRectFrame(0, 0, 20, 20)]
* button.touchAreaInsets = UIEdgeInsetsMake(-10.0f, -10.0f, -10.0f, -10.0f);
*/
#interface UIControl (Extensions)
#property (nonatomic, assign) UIEdgeInsets touchAreaInsets;
#end
If you're using Material's iOS library for your buttons, you can just use hitAreaInsets to increase the touch target size of the button.
example code from https://material.io/components/buttons/ios#using-buttons
let buttonVerticalInset =
min(0, -(kMinimumAccessibleButtonSize.height - button.bounds.height) / 2);
let buttonHorizontalInset =
min(0, -(kMinimumAccessibleButtonSize.width - button.bounds.width) / 2);
button.hitAreaInsets =
UIEdgeInsetsMake(buttonVerticalInset, buttonHorizontalInset,
buttonVerticalInset, buttonHorizontalInset);
Swift 5:
UIButton subclass implementation (for programmatically created buttons).
Tap area rect can be specified as either:
Absolute rect
Edge insets (e.g. 'top:left:bottom:right')
Note: changeTapAreaBy() is applied to button's initial bounds,
unless there are previous tap area adjustments, otherwise, to those.
Usage:
let image = UIImage(systemName: "figure.surfing")
let button = UIButton.systemButton(with: image, target: nil, action: nil)
button.changeTapAreaBy(insets: UIEdgeInsets(top: -5, left: -5, bottom: 5, right: 5)
Implementation (Swift 5):
import UIKit
class ConfigurableTapAreaButton : UIButton {
var tapRect = CGRect.zero
override init(frame: CGRect) {
super.init(frame: frame)
tapRect = bounds
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
return tapRect.contains(point)
}
func setTapArea(rect: CGRect) {
tapRect = rect
}
func changeTapAreaBy(insets: UIEdgeInsets) {
let dx = insets.left
let dy = insets.top
let dw = insets.right - dx
let dh = insets.bottom - dy
tapRect = CGRect( x: tapRect.origin.x + dx,
y: tapRect.origin.y + dy,
width: tapRect.size.width + dw,
height: tapRect.size.height + dh)
}
}