SwiftUI Dot indicator
Your code is perfectly fine. Just a couple of comments:

It is bad-to-impossible to have any internal state in a View.

Actually SwiftUI was designed to handle state changes directly in a view. For simple cases you can easily do:
struct PageIndicator: View {
    @State private var currentPage = 0
    let numPages: Int = 5

    private let spacing: CGFloat = 2
    private let dotSize: CGFloat = 8

    var body: some View {
        VStack {
            HStack(alignment: .center, spacing: spacing) {
                ForEach(0 ..&lt; numPages, id: \.self) {
                    DotIndicator(pageIndex: $0, isOn: self.$currentPage)
                        .frame(width: self.dotSize, height: self.dotSize)
                }
            }
        }
    }
}

PageIndicator(numPages: 5)

What's more sometimes an @ObservedObject may be reinitialised contrary to a @State property (unless you use a @StateObject available in SwiftUI 2.0).
For more information see: What is the difference between ObservedObject and StateObject in SwiftUI.

No logic in the View or so they say. Yet here and there in the code
samples I see ternary operators and loops.

True, but for simple cases creating a whole new ViewModel can actually make code less readable (as in the example above). Note that a ForEach loop is perfectly valid in a view.

Apart from the comments above you can use an Image(systemName:) instead of a Circle:
struct DotIndicator: View {
    let pageIndex: Int

    @Binding var isOn: Int

    var body: some View {
        Button(action: {
            self.isOn = self.pageIndex
        }) {
            Image(systemName: &quot;circle.fill&quot;)
                .imageScale(.small)
                .scaleEffect(isOn == pageIndex ? 1.0 : 0.7)
                .animation(.spring())
        }
    }
}

Because a Circle consumes all available space, you need to constrain it with the .frame modifier. You don't have to do that with an Image - it reduces the code (dotSize is no longer needed) and scales automatically.