The basic idea is that you subclass MKAnnotationView
and set its image
.
You can implement the button below the annotation view any way you want, but make sure to implement a hitTest(_:with:)
so that taps are correctly recognized.
E.g.
class CustomAnnotationView: MKAnnotationView {
static let lineWidth: CGFloat = 2
static let pinSize = CGSize(width: 30, height: 37)
static let pinImage: UIImage = image(with: .blue)
private let button: UIButton = DisclosureIndicatorButton()
override init(annotation: MKAnnotation?, reuseIdentifier: String?) {
super.init(annotation: annotation, reuseIdentifier: reuseIdentifier)
image = Self.pinImage
centerOffset = CGPoint(x: 0, y: -Self.pinSize.height / 2)
configure(for: annotation)
configureButton()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var annotation: MKAnnotation? {
didSet {
configure(for: annotation)
}
}
// this is required for a tap on the callout/button below the annotation view to be recognized
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
guard let hitView = super.hitTest(point, with: event) else {
let pointInButton = convert(point, to: button)
return button.hitTest(pointInButton, with: event)
}
return hitView
}
}
private extension CustomAnnotationView {
func configure(for annotation: MKAnnotation?) {
canShowCallout = false
button.setTitle(annotation?.title ?? "Unknown", for: .normal)
// if you were also doing clustering, you do that configuration here ...
}
func configureButton() {
addSubview(button)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: centerXAnchor),
button.topAnchor.constraint(equalTo: bottomAnchor, constant: 20)
])
button.addTarget(self, action: #selector(didTapButton(_:)), for: .touchUpInside)
}
/// Function to create pin image of the desired color
///
/// You could obviously just create an pre-rendered image in your asset collection,
/// but I find it just as easy to render them programmatically.
static func image(with color: UIColor) -> UIImage {
UIGraphicsImageRenderer(size: pinSize).image { _ in
let bounds = CGRect(origin: .zero, size: pinSize)
let rect = bounds.insetBy(dx: lineWidth / 2, dy: lineWidth / 2)
let r = rect.width / 2
let h = rect.height - r
let theta = acos(r / h)
let center = CGPoint(x: rect.midX, y: rect.midX)
let path = UIBezierPath(arcCenter: center, radius: r, startAngle: .pi / 2 - theta, endAngle: .pi / 2 + theta, clockwise: false)
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
path.lineWidth = lineWidth
path.close()
color.setFill()
path.fill()
UIColor.white.setStroke()
path.stroke()
let path2 = UIBezierPath(arcCenter: center, radius: r / 3, startAngle: 0, endAngle: .pi * 2, clockwise: true)
UIColor.white.setFill()
path2.fill()
}
}
/// Button handler
///
/// Note, taps on the button are passed to map view delegate via
/// mapView(_:annotationView:calloutAccessoryControlTapped)`.
///
/// Obviously, you could use your own delegate protocol if you wanted.
@objc func didTapButton(_ sender: Any) {
if let mapView = mapView, let delegate = mapView.delegate {
delegate.mapView?(mapView, annotationView: self, calloutAccessoryControlTapped: button)
}
}
/// Map view
///
/// Navigate up view hierarchy until we find `MKMapView`.
var mapView: MKMapView? {
var view = superview
while view != nil {
if let mapView = view as? MKMapView { return mapView }
view = view?.superview
}
return nil
}
}
That results in:

The creation of the button below the annotation view is beyond the scope of the question and could be created like one of the answers discussed in How do I put the image on the right side of the text in a UIButton? In the above example, I’ve just wrapped one of the those solutions in its own class, DisclosureIndicatorButton
, but I don’t really want to wade into the debate about the preferred approach, so I’ll let you pick whichever you want.